diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 7435df6f..235fee48 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -1,11 +1,11 @@
- 2.29.0-local
+ 2.42.1-local
$(Version)
PepperDash Technology
PepperDash Technology
PepperDash Essentials
- Copyright © 2025
+ Copyright © 2026
https://github.com/PepperDash/Essentials
git
Crestron; 4series
@@ -20,4 +20,7 @@
+
+
+
diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs
index df44ab51..546a2a67 100644
--- a/src/PepperDash.Core/Comm/GenericSshClient.cs
+++ b/src/PepperDash.Core/Comm/GenericSshClient.cs
@@ -151,6 +151,8 @@ namespace PepperDash.Core
// Thread-safety lock for state changes
private readonly object _stateLock = new object();
+ private volatile bool _isProgramStopping;
+
private bool disconnectLogged = false;
///
@@ -207,11 +209,9 @@ namespace PepperDash.Core
{
if (programEventType == eProgramStatusEventType.Stopping)
{
- if (client != null)
- {
- this.LogDebug("Program stopping. Closing connection");
- Disconnect();
- }
+ _isProgramStopping = true;
+ this.LogDebug("Program stopping. Closing connection");
+ Disconnect();
}
}
@@ -228,6 +228,12 @@ namespace PepperDash.Core
return;
}
+ if (_isProgramStopping)
+ {
+ this.LogDebug("Skipping connect because program is stopping");
+ return;
+ }
+
ConnectEnabled = true;
try
@@ -287,13 +293,7 @@ namespace PepperDash.Core
}
catch (SshConnectionException e)
{
- var ie = e.InnerException; // The details are inside!!
-
- if (ie is SocketException)
- {
- this.LogError("CONNECTION failure: Cannot reach host");
- this.LogVerbose(ie, "Exception details: ");
- }
+ var ie = e.InnerException; // The details are inside, when present - remote can close the connection with no inner exception at all
if (ie is System.Net.Sockets.SocketException socketException)
{
@@ -301,20 +301,20 @@ namespace PepperDash.Core
Hostname, Port);
this.LogVerbose(socketException, "SocketException details: ");
}
- if (ie is SshAuthenticationException)
+ else if (ie is SshAuthenticationException)
{
this.LogError("Authentication failure for username {userName}", Username);
this.LogVerbose(ie, "AuthenticationException details: ");
}
else
{
- this.LogError("Error on connect: {error}", ie.Message);
- this.LogVerbose(ie, "Exception details: ");
+ this.LogError("Error on connect: {error}", ie?.Message ?? e.Message);
+ this.LogVerbose(ie ?? e, "Exception details: ");
}
disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
- if (AutoReconnect)
+ if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
{
this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer();
@@ -326,7 +326,7 @@ namespace PepperDash.Core
disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
- if (AutoReconnect)
+ if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
{
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer();
@@ -338,7 +338,7 @@ namespace PepperDash.Core
this.LogVerbose(e, "Exception details: ");
disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
- if (AutoReconnect)
+ if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
{
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer();
@@ -473,7 +473,7 @@ namespace PepperDash.Core
{
connectLock.Release();
}
- if (AutoReconnect && ConnectEnabled)
+ if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
{
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer();
@@ -516,7 +516,10 @@ namespace PepperDash.Core
this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim());
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
- StartReconnectTimer();
+ if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
+ {
+ StartReconnectTimer();
+ }
}
catch (Exception ex)
{
@@ -549,7 +552,10 @@ namespace PepperDash.Core
this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes));
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
- StartReconnectTimer();
+ if (AutoReconnect && ConnectEnabled && !_isProgramStopping)
+ {
+ StartReconnectTimer();
+ }
}
catch (Exception ex)
{
diff --git a/src/PepperDash.Core/PepperDash.Core.csproj b/src/PepperDash.Core/PepperDash.Core.csproj
index daa5c6da..747aefb6 100644
--- a/src/PepperDash.Core/PepperDash.Core.csproj
+++ b/src/PepperDash.Core/PepperDash.Core.csproj
@@ -43,7 +43,7 @@
-
+
diff --git a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs
index 298bbc9b..a5aa0d13 100644
--- a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs
+++ b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs
@@ -39,6 +39,8 @@ namespace PepperDash.Essentials.Core
ICec Port;
+ bool _cecSubscribed;
+
///
/// Constructor
///
@@ -54,7 +56,7 @@ namespace PepperDash.Essentials.Core
{
Port = postActivationFunc(config);
- Port.StreamCec.CecChange += StreamCec_CecChange;
+ TryEnsureCecSubscription();
});
}
@@ -68,7 +70,29 @@ namespace PepperDash.Essentials.Core
{
Port = port;
+ TryEnsureCecSubscription();
+ }
+
+ ///
+ /// Subscribes to the CEC change event once is available.
+ /// Safe to call repeatedly; the subscription is only wired a single time.
+ /// If StreamCec is null during construction, this is retried when send methods invoke
+ /// this method later.
+ ///
+ void TryEnsureCecSubscription()
+ {
+ if (_cecSubscribed)
+ return;
+
+ if (Port?.StreamCec == null)
+ {
+ Debug.LogMessage(LogEventLevel.Warning, this, "StreamCec is not available; CEC feedback is deferred until the device is ready");
+ return;
+ }
+
Port.StreamCec.CecChange += new CecChangeEventHandler(StreamCec_CecChange);
+ _cecSubscribed = true;
+ Debug.LogMessage(LogEventLevel.Information, this, "Subscribed to CEC feedback");
}
void StreamCec_CecChange(Cec cecDevice, CecEventArgs args)
@@ -104,8 +128,9 @@ namespace PepperDash.Essentials.Core
///
public void SendText(string text)
{
- if (Port == null)
+ if (Port?.StreamCec == null)
return;
+ TryEnsureCecSubscription();
this.PrintSentText(text);
Port.StreamCec.Send.StringValue = text;
}
@@ -115,8 +140,9 @@ namespace PepperDash.Essentials.Core
///
public void SendBytes(byte[] bytes)
{
- if (Port == null)
+ if (Port?.StreamCec == null)
return;
+ TryEnsureCecSubscription();
var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length);
this.PrintSentBytes(bytes);
Debug.LogMessage(LogEventLevel.Information, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes));
diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs
index 40c6c0cf..37cd4eba 100644
--- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs
+++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs
@@ -135,12 +135,14 @@ namespace PepperDash.Essentials.Core.Config
{
var parsedConfig = JObject.Parse(fs.ReadToEnd());
- // Check if it's a v2 config (check for "version" node)
- // this means it's already merged by the Portal API
- // from the v2 config tool
- var isV2Config = parsedConfig["versions"] != null;
-
- if (isV2Config)
+ // A config is v1 if it has separate "system" and "template" nodes that
+ // need to be merged. A v2 config is already merged by the Portal API and
+ // will not have "system"/"template" nodes. This is independent of whether
+ // a "versions" node is present, which only carries version metadata and
+ // can appear on either a v1 or v2 config.
+ var isV1Config = parsedConfig["system"] != null && parsedConfig["template"] != null;
+
+ if (!isV1Config)
{
Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary.");
ConfigObject = parsedConfig.ToObject();
@@ -148,6 +150,8 @@ namespace PepperDash.Essentials.Core.Config
return true;
}
+ Debug.LogMessage(LogEventLevel.Information, "Config file is a v1 format, merging system and template.");
+
// Extract SystemUrl and TemplateUrl into final config output
ConfigObject = PortalConfigReader.MergeConfigs(parsedConfig).ToObject();
@@ -160,6 +164,13 @@ namespace PepperDash.Essentials.Core.Config
{
ConfigObject.TemplateUrl = parsedConfig["template_url"].Value();
}
+
+ // MergeConfigs does not carry the "versions" node forward, so it must be
+ // applied separately to ensure it's preserved in the merged config.
+ if (parsedConfig["versions"] != null)
+ {
+ ConfigObject.Versions = parsedConfig["versions"].ToObject();
+ }
}
Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config");
diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs
index 6ffe07d2..44275ef0 100644
--- a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs
+++ b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs
@@ -105,6 +105,7 @@ namespace PepperDash.Essentials.Core.Config
///
/// Gets or sets the Versions
///
+ [JsonProperty("versions")]
public VersionData Versions { get; set; }
///
@@ -134,12 +135,25 @@ namespace PepperDash.Essentials.Core.Config
[JsonProperty("packages")]
public List Packages { get; set; }
+ ///
+ /// Gets or sets the touchpanel wrapper app version
+ ///
+ [JsonProperty("touchpanelWrapperApp")]
+ public NugetVersion TouchpanelWrapperApp { get; set; }
+
+ ///
+ /// Gets or sets the list of user interface packages
+ ///
+ [JsonProperty("userInterfaces")]
+ public List UserInterfaces { get; set; }
+
///
/// Initializes a new instance of the class.
///
public VersionData()
{
Packages = new List();
+ UserInterfaces = new List();
}
}
@@ -157,8 +171,20 @@ namespace PepperDash.Essentials.Core.Config
///
/// Gets or sets the PackageId
///
- [JsonProperty("packageId")]
+ [JsonProperty("packageId", NullValueHandling = NullValueHandling.Ignore)]
public string PackageId { get; set; }
+
+ ///
+ /// Gets or sets the RepoUrl
+ ///
+ [JsonProperty("repoUrl", NullValueHandling = NullValueHandling.Ignore)]
+ public string RepoUrl { get; set; }
+
+ ///
+ /// Gets or sets the human-readable name
+ ///
+ [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)]
+ public string Name { get; set; }
}
///
diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs
index aca38333..6c6e5598 100644
--- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs
+++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs
@@ -80,7 +80,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
}
///
- /// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes.
+ /// Enumeration of network switch port state change event types (e.g. VLAN changes or PoE state changes).
///
public enum NetworkSwitchPortEventType
{
@@ -95,7 +95,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
VlanChangeInProgress,
///
- /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan
+ /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan or an external change detected by polling.
///
VlanChanged,
@@ -105,7 +105,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
PoeDisableInProgress,
///
- /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState
+ /// Indicates that PoE has been disabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling.
///
PoEDisabled,
@@ -115,7 +115,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
PoeEnableInProgress,
///
- /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState
+ /// Indicates that PoE has been enabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling.
///
PoEEnabled
}
diff --git a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs
new file mode 100644
index 00000000..c628129e
--- /dev/null
+++ b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs
@@ -0,0 +1,14 @@
+namespace PepperDash.Essentials.Core
+{
+ ///
+ /// Defines the contract for IHasDspPresetSave
+ ///
+ public interface IHasDspPresetSave : IDspPresets
+ {
+ ///
+ /// Saves the preset by key
+ ///
+ /// key of preset to save
+ void SavePreset(string key);
+ }
+}
\ No newline at end of file
diff --git a/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs b/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs
index 3db61ebd..ee20a22a 100644
--- a/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs
+++ b/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs
@@ -30,7 +30,7 @@ namespace PepperDash.Essentials.Core
{
if (IsInAutoMode)
{
- return _partitionSensor?.PartitionPresentFeedback?.BoolValue ?? _partitionPresent;
+ return _partitionSensor.PartitionPresentFeedback.BoolValue;
}
return _partitionPresent;
diff --git a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj
index 251ba316..b7f333bd 100644
--- a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj
+++ b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj
@@ -25,7 +25,7 @@
bin\$(Configuration)\PepperDash_Essentials_Core.xml
-
+
diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs
index 7ca54c7a..a45adb27 100644
--- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs
+++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs
@@ -5,7 +5,6 @@ using Serilog.Events;
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -18,7 +17,7 @@ namespace PepperDash.Essentials.Core
/// combinations based on partition states and predefined scenarios. It supports both automatic and manual modes
/// for managing room combinations. In automatic mode, the device determines the current room combination scenario
/// based on partition sensor states. In manual mode, scenarios can be set explicitly by the user.
- public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombinerWithOperationStatus
+ public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombiner
{
private EssentialsRoomCombinerPropertiesConfig _propertiesConfig;
@@ -76,29 +75,10 @@ namespace PepperDash.Essentials.Core
private CTimer _scenarioChangeDebounceTimer;
- private CTimer _combinationOperationTimeoutTimer;
-
private int _scenarioChangeDebounceTimeSeconds = 10; // default to 10s
- private const int DefaultCombinationOperationTimeoutSeconds = 300;
-
- private int _combinationOperationTimeoutSeconds = DefaultCombinationOperationTimeoutSeconds;
-
private Mutex _scenarioChange = new Mutex();
- private readonly object _combinationOperationLock = new object();
-
- private readonly List _operationStatusProviderDevices = new List();
-
- private string _pendingCompletionOperationId;
-
- private string _pendingCompletionScenarioKey;
-
- private CombinationOperationStatus _combinationOperation = new CombinationOperationStatus
- {
- State = CombinationOperationState.Idle
- };
-
///
/// Initializes a new instance of the class, which manages room combination
/// scenarios and partition states.
@@ -125,12 +105,6 @@ namespace PepperDash.Essentials.Core
_scenarioChangeDebounceTimeSeconds = _propertiesConfig.ScenarioChangeDebounceTimeSeconds;
}
- if (_propertiesConfig.CombinationOperationTimeoutSeconds.HasValue
- && _propertiesConfig.CombinationOperationTimeoutSeconds.Value > 0)
- {
- _combinationOperationTimeoutSeconds = _propertiesConfig.CombinationOperationTimeoutSeconds.Value;
- }
-
IsInAutoModeFeedback = new BoolFeedback(() => _isInAutoMode);
// default to auto mode
@@ -159,8 +133,6 @@ namespace PepperDash.Essentials.Core
// connected and initialized
DeviceManager.AllDevicesInitialized += (o, a) =>
{
- InitializeOperationStatusProviders();
-
if (IsInAutoMode)
{
DetermineRoomCombinationScenario();
@@ -284,19 +256,13 @@ namespace PepperDash.Essentials.Core
private async Task ChangeScenario(IRoomCombinationScenario newScenario)
{
- if (newScenario == _currentScenario)
- {
- return;
- }
+
- var operationId = SetCombinationOperationStatus(
- CombinationOperationState.InProgress,
- newScenario != null ? newScenario.Key : null,
- null,
- true);
+ if (newScenario == _currentScenario)
+ {
+ return;
+ }
- try
- {
// Deactivate the old scenario first
if (_currentScenario != null)
{
@@ -315,20 +281,7 @@ namespace PepperDash.Essentials.Core
RoomCombinationScenarioChanged?.Invoke(this, new EventArgs());
- TryCompleteCombinationOperationIfReady(
- operationId,
- _currentScenario != null ? _currentScenario.Key : null);
- }
- catch (Exception ex)
- {
- this.LogException(ex, "Error changing room combination scenario");
-
- TrySetCombinationOperationTerminalStatus(
- operationId,
- CombinationOperationState.Failed,
- newScenario != null ? newScenario.Key : null,
- "Combination operation failed");
- }
+
}
#region IEssentialsRoomCombiner Members
@@ -340,11 +293,6 @@ namespace PepperDash.Essentials.Core
/// changes. Subscribers can use this event to update their logic or UI based on the new scenario.
public event EventHandler RoomCombinationScenarioChanged;
- ///
- /// Occurs when the room combination operation status changes.
- ///
- public event EventHandler CombinationOperationStatusChanged;
-
///
/// Gets the current room combination scenario.
///
@@ -356,20 +304,6 @@ namespace PepperDash.Essentials.Core
}
}
- ///
- /// Gets the current room combination operation status.
- ///
- public CombinationOperationStatus CombinationOperation
- {
- get
- {
- lock (_combinationOperationLock)
- {
- return CloneCombinationOperationStatus(_combinationOperation);
- }
- }
- }
-
///
/// Gets or sets the IsInAutoModeFeedback
///
@@ -521,299 +455,6 @@ namespace PepperDash.Essentials.Core
}
#endregion
-
- private string SetCombinationOperationStatus(
- CombinationOperationState state,
- string scenarioKey,
- string message,
- bool resetStartedUtc)
- {
- string operationId;
-
- lock (_combinationOperationLock)
- {
- if (resetStartedUtc)
- {
- _combinationOperation = new CombinationOperationStatus
- {
- OperationId = Guid.NewGuid().ToString(),
- ScenarioKey = scenarioKey,
- StartedUtc = DateTime.UtcNow.ToString("o"),
- State = state,
- Message = message
- };
- }
- else
- {
- _combinationOperation.ScenarioKey = scenarioKey ?? _combinationOperation.ScenarioKey;
- _combinationOperation.State = state;
- _combinationOperation.Message = message;
- }
-
- if (state == CombinationOperationState.InProgress)
- {
- _pendingCompletionOperationId = null;
- _pendingCompletionScenarioKey = null;
- }
-
- operationId = _combinationOperation.OperationId;
- }
-
- if (state == CombinationOperationState.InProgress)
- {
- StartCombinationOperationTimeout(operationId);
- }
- else if (state == CombinationOperationState.Completed
- || state == CombinationOperationState.Failed
- || state == CombinationOperationState.TimedOut
- || state == CombinationOperationState.Idle)
- {
- StopCombinationOperationTimeout();
- }
-
- CombinationOperationStatusChanged?.Invoke(this, EventArgs.Empty);
-
- return operationId;
- }
-
- private void TrySetCombinationOperationTerminalStatus(
- string operationId,
- CombinationOperationState state,
- string scenarioKey,
- string message)
- {
- var statusUpdated = false;
-
- lock (_combinationOperationLock)
- {
- if (_combinationOperation == null
- || !string.Equals(_combinationOperation.OperationId, operationId, StringComparison.Ordinal)
- || _combinationOperation.State != CombinationOperationState.InProgress)
- {
- return;
- }
-
- _combinationOperation.ScenarioKey = scenarioKey ?? _combinationOperation.ScenarioKey;
- _combinationOperation.State = state;
- _combinationOperation.Message = message;
- _pendingCompletionOperationId = null;
- _pendingCompletionScenarioKey = null;
- statusUpdated = true;
- }
-
- if (!statusUpdated)
- {
- return;
- }
-
- StopCombinationOperationTimeout();
- CombinationOperationStatusChanged?.Invoke(this, EventArgs.Empty);
- }
-
- private void StartCombinationOperationTimeout(string operationId)
- {
- StopCombinationOperationTimeout();
-
- if (_combinationOperationTimeoutSeconds <= 0 || string.IsNullOrEmpty(operationId))
- {
- return;
- }
-
- _combinationOperationTimeoutTimer = new CTimer(
- _ => HandleCombinationOperationTimeout(operationId),
- _combinationOperationTimeoutSeconds * 1000);
- }
-
- private void StopCombinationOperationTimeout()
- {
- if (_combinationOperationTimeoutTimer == null)
- {
- return;
- }
-
- _combinationOperationTimeoutTimer.Dispose();
- _combinationOperationTimeoutTimer = null;
- }
-
- private void HandleCombinationOperationTimeout(string operationId)
- {
- TrySetCombinationOperationTerminalStatus(
- operationId,
- CombinationOperationState.TimedOut,
- null,
- "Combination operation timed out");
- }
-
- private void InitializeOperationStatusProviders()
- {
- _operationStatusProviderDevices.Clear();
-
- foreach (var device in DeviceManager.AllDevices)
- {
- if (IsOperationStatusProviderDevice(device))
- {
- _operationStatusProviderDevices.Add(device);
- SubscribeToOperationStatusProviderChanged(device);
- }
- }
-
- this.LogDebug("Room combiner {combinerKey} found {providerCount} post-combination status provider(s)", Key, _operationStatusProviderDevices.Count);
- }
-
- private static bool IsOperationStatusProviderDevice(object device)
- {
- if (device == null)
- {
- return false;
- }
-
- var type = device.GetType();
-
- var roomCombinerKeyProperty = type.GetProperty("RoomCombinerKey", BindingFlags.Instance | BindingFlags.Public);
- var scenarioReconciledProperty = type.GetProperty("ScenarioReconciled", BindingFlags.Instance | BindingFlags.Public);
- var scenarioReconciledScenarioKeyProperty = type.GetProperty("ScenarioReconciledScenarioKey", BindingFlags.Instance | BindingFlags.Public);
-
- return roomCombinerKeyProperty != null
- && roomCombinerKeyProperty.PropertyType == typeof(string)
- && roomCombinerKeyProperty.CanRead
- && scenarioReconciledProperty != null
- && scenarioReconciledProperty.PropertyType == typeof(bool)
- && scenarioReconciledProperty.CanRead
- && scenarioReconciledScenarioKeyProperty != null
- && scenarioReconciledScenarioKeyProperty.PropertyType == typeof(string)
- && scenarioReconciledScenarioKeyProperty.CanRead;
- }
-
- private void SubscribeToOperationStatusProviderChanged(object device)
- {
- var eventInfo = device.GetType().GetEvent("ScenarioReconciledChanged", BindingFlags.Instance | BindingFlags.Public);
- if (eventInfo == null)
- {
- return;
- }
-
- if (eventInfo.EventHandlerType != typeof(EventHandler))
- {
- this.LogDebug("Room combiner {combinerKey} skipping provider event subscription for {providerType}: unsupported event type {eventType}", Key, device.GetType().Name, eventInfo.EventHandlerType);
- return;
- }
-
- eventInfo.AddEventHandler(device, new EventHandler(OperationStatusProvider_ScenarioReconciledChanged));
- }
-
- private void OperationStatusProvider_ScenarioReconciledChanged(object sender, EventArgs e)
- {
- TryCompletePendingCombinationOperation();
- }
-
- private void TryCompletePendingCombinationOperation()
- {
- string operationId;
- string scenarioKey;
-
- lock (_combinationOperationLock)
- {
- operationId = _pendingCompletionOperationId;
- scenarioKey = _pendingCompletionScenarioKey;
- }
-
- if (string.IsNullOrEmpty(operationId))
- {
- return;
- }
-
- TryCompleteCombinationOperationIfReady(operationId, scenarioKey);
- }
-
- private void TryCompleteCombinationOperationIfReady(string operationId, string scenarioKey)
- {
- if (string.IsNullOrEmpty(operationId))
- {
- return;
- }
-
- if (!AreOperationStatusProvidersSatisfied(scenarioKey))
- {
- lock (_combinationOperationLock)
- {
- if (_combinationOperation != null
- && string.Equals(_combinationOperation.OperationId, operationId, StringComparison.Ordinal)
- && _combinationOperation.State == CombinationOperationState.InProgress)
- {
- _pendingCompletionOperationId = operationId;
- _pendingCompletionScenarioKey = scenarioKey;
- }
- }
-
- return;
- }
-
- TrySetCombinationOperationTerminalStatus(
- operationId,
- CombinationOperationState.Completed,
- scenarioKey,
- null);
- }
-
- private bool AreOperationStatusProvidersSatisfied(string scenarioKey)
- {
- var matchingProviders = _operationStatusProviderDevices
- .Where(d => string.Equals(GetStringPropertyValue(d, "RoomCombinerKey"), Key, StringComparison.OrdinalIgnoreCase))
- .ToList();
-
- if (!matchingProviders.Any())
- {
- return true;
- }
-
- foreach (var provider in matchingProviders)
- {
- var providerScenarioReconciled = GetBoolPropertyValue(provider, "ScenarioReconciled");
- var providerScenarioKey = GetStringPropertyValue(provider, "ScenarioReconciledScenarioKey");
-
- if (!providerScenarioReconciled
- || !string.Equals(providerScenarioKey, scenarioKey, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
- }
-
- return true;
- }
-
- private static string GetStringPropertyValue(object target, string propertyName)
- {
- var propertyInfo = target.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
- return propertyInfo != null ? propertyInfo.GetValue(target, null) as string : null;
- }
-
- private static bool GetBoolPropertyValue(object target, string propertyName)
- {
- var propertyInfo = target.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
- if (propertyInfo == null || propertyInfo.PropertyType != typeof(bool))
- {
- return false;
- }
-
- return (bool)propertyInfo.GetValue(target, null);
- }
-
- private static CombinationOperationStatus CloneCombinationOperationStatus(CombinationOperationStatus status)
- {
- if (status == null)
- {
- return null;
- }
-
- return new CombinationOperationStatus
- {
- OperationId = status.OperationId,
- ScenarioKey = status.ScenarioKey,
- StartedUtc = status.StartedUtc,
- State = status.State,
- Message = status.Message
- };
- }
}
///
diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs
index 13287c72..868f3992 100644
--- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs
+++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs
@@ -11,10 +11,10 @@ namespace PepperDash.Essentials.Core
///
public class EssentialsRoomCombinerPropertiesConfig
{
- ///
- /// Gets or sets a value indicating whether the system operates in automatic mode.
- /// Some systems don't have partitions sensors, and show shouldn't allow auto mode to be turned on. When this is true in the configuration,
- /// auto mode won't be allowed to be turned on.
+ ///
+ /// Gets or sets a value indicating whether the system operates in automatic mode.
+ /// Some systems don't have partitions sensors, and show shouldn't allow auto mode to be turned on. When this is true in the configuration,
+ /// auto mode won't be allowed to be turned on.
///
[JsonProperty("disableAutoMode")]
public bool DisableAutoMode { get; set; }
@@ -49,18 +49,11 @@ namespace PepperDash.Essentials.Core
[JsonProperty("defaultScenarioKey")]
public string defaultScenarioKey { get; set; }
- ///
- /// Gets or sets the debounce time, in seconds, for scenario changes.
+ ///
+ /// Gets or sets the debounce time, in seconds, for scenario changes.
///
[JsonProperty("scenarioChangeDebounceTimeSeconds")]
public int ScenarioChangeDebounceTimeSeconds { get; set; }
-
- ///
- /// Gets or sets the timeout, in seconds, for room combination operations.
- /// When null or less than or equal to zero, the default timeout is used.
- ///
- [JsonProperty("combinationOperationTimeoutSeconds", NullValueHandling = NullValueHandling.Ignore)]
- public int? CombinationOperationTimeoutSeconds { get; set; }
}
///
@@ -68,14 +61,14 @@ namespace PepperDash.Essentials.Core
///
public class PartitionConfig : IKeyName
{
- ///
- /// Gets or sets the unique key associated with the object.
+ ///
+ /// Gets or sets the unique key associated with the object.
///
[JsonProperty("key")]
public string Key { get; set; }
- ///
- /// Gets or sets the name associated with the object.
+ ///
+ /// Gets or sets the name associated with the object.
///
[JsonProperty("name")]
public string Name { get; set; }
@@ -98,26 +91,26 @@ namespace PepperDash.Essentials.Core
///
public class RoomCombinationScenarioConfig : IKeyName
{
- ///
- /// Gets or sets the key associated with the object.
+ ///
+ /// Gets or sets the key associated with the object.
///
[JsonProperty("key")]
public string Key { get; set; }
- ///
- /// Gets or sets the name associated with the object.
+ ///
+ /// Gets or sets the name associated with the object.
///
[JsonProperty("name")]
public string Name { get; set; }
- ///
- /// Gets or sets a value indicating whether to hide this scenario in the UI.
+ ///
+ /// Gets or sets a value indicating whether to hide this scenario in the UI.
///
[JsonProperty("hideInUi", NullValueHandling = NullValueHandling.Ignore)]
- public bool HideInUi { get; set; }
-
- ///
- /// Gets or sets the collection of partition states.
+ public bool HideInUi { get; set; }
+
+ ///
+ /// Gets or sets the collection of partition states.
///
[JsonProperty("partitionStates")]
public List PartitionStates { get; set; }
@@ -128,14 +121,14 @@ namespace PepperDash.Essentials.Core
[JsonProperty("uiMap")]
public Dictionary UiMap { get; set; }
- ///
- /// Gets or sets the list of actions to be performed during device activation.
+ ///
+ /// Gets or sets the list of actions to be performed during device activation.
///
[JsonProperty("activationActions")]
public List ActivationActions { get; set; }
- ///
- /// Gets or sets the list of actions to be performed when a device is deactivated.
+ ///
+ /// Gets or sets the list of actions to be performed when a device is deactivated.
///
[JsonProperty("deactivationActions")]
public List DeactivationActions { get; set; }
@@ -146,14 +139,14 @@ namespace PepperDash.Essentials.Core
///
public class PartitionState
{
- ///
- /// Gets or sets the partition key used to group and organize data within a storage system.
+ ///
+ /// Gets or sets the partition key used to group and organize data within a storage system.
///
[JsonProperty("partitionKey")]
public string PartitionKey { get; set; }
- ///
- /// Gets or sets a value indicating whether a partition is currently present.
+ ///
+ /// Gets or sets a value indicating whether a partition is currently present.
///
[JsonProperty("partitionSensedState")]
public bool PartitionPresent { get; set; }
diff --git a/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs b/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs
index 641993ea..34d0a0a0 100644
--- a/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs
+++ b/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs
@@ -16,14 +16,12 @@ namespace PepperDash.Essentials.Core
///
event EventHandler RoomCombinationScenarioChanged;
-
///
/// The current room combination scenario
///
[JsonProperty("currentScenario")]
IRoomCombinationScenario CurrentScenario { get; }
-
///
/// When true, indicates the current mode is auto mode
///
@@ -87,56 +85,6 @@ namespace PepperDash.Essentials.Core
void SetRoomCombinationScenario(string scenarioKey);
}
- ///
- /// Optional extension for room combiners that provide operation lifecycle status.
- ///
- public interface IEssentialsRoomCombinerWithOperationStatus : IEssentialsRoomCombiner
- {
- ///
- /// Indicates that the room combination operation status has changed.
- ///
- event EventHandler CombinationOperationStatusChanged;
-
- ///
- /// Gets the current room combination operation status.
- ///
- [JsonProperty("combinationOperation")]
- CombinationOperationStatus CombinationOperation { get; }
- }
-
- ///
- /// Defines lifecycle states for a room combination operation.
- ///
- public enum CombinationOperationState
- {
- Idle,
- InProgress,
- Completed,
- Failed,
- TimedOut
- }
-
- ///
- /// Represents room combination operation status details.
- ///
- public class CombinationOperationStatus
- {
- [JsonProperty("operationId", NullValueHandling = NullValueHandling.Ignore)]
- public string OperationId { get; set; }
-
- [JsonProperty("scenarioKey", NullValueHandling = NullValueHandling.Ignore)]
- public string ScenarioKey { get; set; }
-
- [JsonProperty("startedUtc", NullValueHandling = NullValueHandling.Ignore)]
- public string StartedUtc { get; set; }
-
- [JsonProperty("state")]
- public CombinationOperationState State { get; set; }
-
- [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)]
- public string Message { get; set; }
- }
-
///
/// Represents a scenario for combining rooms, including activation, deactivation, and associated state.
///
diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs
index 2b5b3c8c..303a0194 100644
--- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs
+++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs
@@ -348,9 +348,9 @@ namespace PepperDash.Essentials.Core
var sources = DeviceManager.AllDevices.OfType()
.Where(d => !(d is IRoutingInputsOutputs)).ToList();
- foreach (var sink in sinks.Where(d => !(d is IRoutingInputsOutputs)))
+ foreach (var sink in sinks)
{
- foreach (var source in sources.Where(d => !(d is IRoutingInputsOutputs)))
+ foreach (var source in sources)
{
foreach (var inputPort in sink.InputPorts)
{
diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs
index 3cdb8433..cfbaa1df 100644
--- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs
+++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs
@@ -95,6 +95,11 @@ namespace PepperDash.Essentials.Core.Web
Name = "ReportVersions",
RouteHandler = new ReportVersionsRequestHandler()
},
+ new HttpCwsRoute("packageManifest")
+ {
+ Name = "GetPackageManifest",
+ RouteHandler = new GetPackageManifestRequestHandler()
+ },
new HttpCwsRoute("appdebug")
{
Name = "AppDebug",
diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs
index 7d947e19..9831fa91 100644
--- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs
+++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs
@@ -52,20 +52,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
var device = DeviceManager.GetDeviceForKey(deviceObj.ToString()) as IHasFeedback;
if (device == null)
{
- context.Response.StatusCode = 200;
- context.Response.StatusDescription = "OK";
- context.Response.ContentType = "application/json";
- context.Response.ContentEncoding = System.Text.Encoding.UTF8;
- var resp = new
- {
- BoolValues = Array.Empty
[JsonProperty("muteOnScreenUp")]
public bool MuteOnScreenUp { get; set; }
+
+ ///
+ /// When true, this controller does NOT automatically lower when its assigned display powers on
+ /// (warms up). Manual Raise/Lower still work, and the power-off auto-raise is unaffected. Intended
+ /// for a projector screen that must not auto-drop in a public space for safety, while the projector
+ /// lift (a separate controller) can still drop automatically.
+ ///
+ [JsonProperty("disableAutoLowerOnPowerOn")]
+ public bool DisableAutoLowerOnPowerOn { get; set; }
+
+ ///
+ /// When true, this controller does NOT automatically raise when its assigned display powers off
+ /// (cools down). Manual Raise/Lower still work, and the power-on auto-lower is unaffected. The
+ /// companion to ; together they make a controller fully
+ /// manual while leaving other controllers (e.g. the lift) on their default automatic behavior.
+ ///
+ [JsonProperty("disableAutoRaiseOnPowerOff")]
+ public bool DisableAutoRaiseOnPowerOff { get; set; }
}
}
diff --git a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj
index 7be4372d..317fa2d3 100644
--- a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj
+++ b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj
@@ -29,6 +29,6 @@
-
+
\ No newline at end of file
diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs
index 4241b69c..c5c0ab65 100644
--- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs
+++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs
@@ -13,7 +13,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// The interfaces implmented by the device sending the messsage
///
[JsonProperty("interfaces", NullValueHandling = NullValueHandling.Ignore)]
- [Obsolete("Interfaces is no longer supported and will be removed in a future release. Interfaces for all devices are now retrieved via the /joinroom endpoint in the MobileControlWebsocketServer")]
+ [Obsolete("Interfaces are no longer supported and will be removed in a future release. Interfaces for all devices are now retrieved via the /joinroom endpoint in the MobileControlWebsocketServer")]
public List Interfaces { get; private set; }
///
diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs
index 65a279f2..966d8d77 100644
--- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs
+++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs
@@ -21,8 +21,6 @@ namespace PepperDash.Essentials.AppServer.Messengers
{
private readonly IEssentialsRoomCombiner _roomCombiner;
- private readonly IEssentialsRoomCombinerWithOperationStatus _roomCombinerWithOperationStatus;
-
///
/// Initializes a new instance of the class, which facilitates
/// messaging for an instance.
@@ -37,7 +35,6 @@ namespace PepperDash.Essentials.AppServer.Messengers
: base(key, messagePath, roomCombiner as IKeyName)
{
_roomCombiner = roomCombiner;
- _roomCombinerWithOperationStatus = roomCombiner as IEssentialsRoomCombinerWithOperationStatus;
}
///
@@ -101,19 +98,6 @@ namespace PepperDash.Essentials.AppServer.Messengers
SendFullStatus();
};
- if (_roomCombinerWithOperationStatus != null)
- {
- _roomCombinerWithOperationStatus.CombinationOperationStatusChanged += (sender, args) =>
- {
- var message = new
- {
- combinationOperation = _roomCombinerWithOperationStatus.CombinationOperation
- };
-
- PostStatusMessage(JToken.FromObject(message));
- };
- }
-
_roomCombiner.IsInAutoModeFeedback.OutputChange += (sender, args) =>
{
var message = new
@@ -154,7 +138,6 @@ namespace PepperDash.Essentials.AppServer.Messengers
DisableAutoMode = _roomCombiner.DisableAutoMode,
IsInAutoMode = _roomCombiner.IsInAutoMode,
CurrentScenario = _roomCombiner.CurrentScenario,
- CombinationOperation = _roomCombinerWithOperationStatus != null ? _roomCombinerWithOperationStatus.CombinationOperation : null,
Rooms = rooms,
RoomCombinationScenarios = _roomCombiner.RoomCombinationScenarios,
Partitions = _roomCombiner.Partitions
@@ -211,12 +194,6 @@ namespace PepperDash.Essentials.AppServer.Messengers
[JsonProperty("currentScenario", NullValueHandling = NullValueHandling.Ignore)]
public IRoomCombinationScenario CurrentScenario { get; set; }
- ///
- /// Gets or sets the room combination operation status.
- ///
- [JsonProperty("combinationOperation", NullValueHandling = NullValueHandling.Ignore)]
- public CombinationOperationStatus CombinationOperation { get; set; }
-
///
/// Gets or sets the collection of rooms associated with the entity.
///
diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj
index d13d1a09..a9223061 100644
--- a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj
+++ b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj
@@ -33,7 +33,7 @@
-
+
diff --git a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs
new file mode 100644
index 00000000..68f5e36e
--- /dev/null
+++ b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs
@@ -0,0 +1,61 @@
+using System;
+using Newtonsoft.Json;
+
+namespace PepperDash.Essentials
+{
+ ///
+ /// Represents the version information reported by a connected Mobile Control UI client
+ ///
+ public class ConnectedClientVersionInfo
+ {
+ ///
+ /// Gets or sets the client id
+ ///
+ [JsonProperty("clientId")]
+ public string ClientId { get; set; }
+
+ ///
+ /// Gets or sets the room key the client joined
+ ///
+ [JsonProperty("roomKey")]
+ public string RoomKey { get; set; }
+
+ ///
+ /// Gets or sets the touchpanel key the client joined as, if any
+ ///
+ [JsonProperty("touchpanelKey")]
+ public string TouchpanelKey { get; set; }
+
+ ///
+ /// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION)
+ ///
+ [JsonProperty("appVersion")]
+ public string AppVersion { get; set; }
+
+ ///
+ /// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured
+ ///
+ [JsonProperty("expectedAppVersion")]
+ public string ExpectedAppVersion { get; set; }
+
+ ///
+ /// Gets or sets the UTC time the client last reported this version
+ ///
+ [JsonProperty("lastSeen")]
+ public DateTime LastSeen { get; set; }
+
+ ///
+ /// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate
+ /// without affecting the internally tracked instance
+ ///
+ public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo
+ {
+ ClientId = ClientId,
+ RoomKey = RoomKey,
+ TouchpanelKey = TouchpanelKey,
+ AppVersion = AppVersion,
+ ExpectedAppVersion = ExpectedAppVersion,
+ LastSeen = LastSeen
+ };
+ }
+}
diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs
index 3d466f5b..c17cf3b9 100644
--- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs
+++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs
@@ -69,11 +69,32 @@ namespace PepperDash.Essentials
private readonly Dictionary _defaultMessengers =
new Dictionary();
+ private readonly Dictionary _connectedClientVersions =
+ new Dictionary(StringComparer.InvariantCultureIgnoreCase);
+
+ private readonly object _connectedClientVersionsLock = new object();
+
///
/// Get the custom messengers with subscriptions
///
public ReadOnlyDictionary Messengers => new ReadOnlyDictionary(_messengers.Values.OfType().ToDictionary(k => k.Key, v => v));
+ ///
+ /// Gets the most recently reported UI app version for each connected client, keyed by clientId
+ ///
+ public ReadOnlyDictionary ConnectedClientVersions
+ {
+ get
+ {
+ lock (_connectedClientVersionsLock)
+ {
+ return new ReadOnlyDictionary(
+ _connectedClientVersions.ToDictionary(kv => kv.Key, kv => kv.Value.Clone())
+ );
+ }
+ }
+ }
+
///
/// Get the default messengers
///
@@ -1782,6 +1803,28 @@ namespace PepperDash.Essentials
" Not Enabled in Config.\r\n"
);
}
+
+ var connectedClientVersions = ConnectedClientVersions;
+
+ if (connectedClientVersions.Count == 0)
+ {
+ CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions: None reported yet\r\n");
+ }
+ else
+ {
+ CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions:\r\n");
+ foreach (var kv in connectedClientVersions)
+ {
+ var v = kv.Value;
+ var match = string.IsNullOrEmpty(v.ExpectedAppVersion) || string.Equals(v.ExpectedAppVersion, v.AppVersion, StringComparison.OrdinalIgnoreCase);
+
+ CrestronConsole.ConsoleCommandResponse(
+ $" Client: {v.ClientId} Touchpanel: {v.TouchpanelKey} Room: {v.RoomKey}\r\n" +
+ $" Reported: {v.AppVersion} Expected: {(string.IsNullOrEmpty(v.ExpectedAppVersion) ? "(not configured)" : v.ExpectedAppVersion)} Match: {(match ? "Yes" : "NO - MISMATCH")}\r\n" +
+ $" Last Seen (UTC): {v.LastSeen:yyyy-MM-dd HH:mm:ss}\r\n"
+ );
+ }
+ }
}
///
@@ -2181,6 +2224,8 @@ namespace PepperDash.Essentials
var roomKey = content["roomKey"].Value();
var touchpanelKey = content.SelectToken("touchpanelKey");
+ TrackClientAppVersion(clientId, roomKey, touchpanelKey?.Value(), content.SelectToken("appVersion")?.Value());
+
if (_roomCombiner == null)
{
var message = new MobileControlMessage
@@ -2252,6 +2297,50 @@ namespace PepperDash.Essentials
SendTouchpanelKey(clientId, touchpanelKey);
}
+ ///
+ /// Records the app version reported by a connecting UI client (e.g. the mobile control React app's
+ /// build-time APP_VERSION) and compares it against the configured versions.touchpanelWrapperApp version.
+ ///
+ private void TrackClientAppVersion(string clientId, string roomKey, string touchpanelKey, string appVersion)
+ {
+ if (string.IsNullOrEmpty(appVersion))
+ {
+ return;
+ }
+
+ var expectedVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version;
+
+ var info = new ConnectedClientVersionInfo
+ {
+ ClientId = clientId,
+ RoomKey = roomKey,
+ TouchpanelKey = touchpanelKey,
+ AppVersion = appVersion,
+ ExpectedAppVersion = expectedVersion,
+ LastSeen = DateTime.UtcNow
+ };
+
+ lock (_connectedClientVersionsLock)
+ {
+ _connectedClientVersions[clientId] = info;
+ }
+
+ if (!string.IsNullOrEmpty(expectedVersion) && !string.Equals(expectedVersion, appVersion, StringComparison.OrdinalIgnoreCase))
+ {
+ this.LogWarning(
+ "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}, which does not match configured versions.touchpanelWrapperApp version {expectedVersion}",
+ clientId, touchpanelKey, appVersion, expectedVersion
+ );
+ }
+ else
+ {
+ this.LogVerbose(
+ "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}",
+ clientId, touchpanelKey, appVersion
+ );
+ }
+ }
+
private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken)
{
if (touchpanelKeyToken == null)
diff --git a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj
index 235e0899..b0e2dd9f 100644
--- a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj
+++ b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj
@@ -38,7 +38,7 @@
-
+
diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs
index 5830782d..262cb34e 100644
--- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs
+++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs
@@ -25,11 +25,28 @@ namespace PepperDash.Essentials.Touchpanel
/// Mobile Control touchpanel controller that provides app control, Zoom integration,
/// and mobile control functionality for Crestron touchpanels.
///
- public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme
+ public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme, ICommunicationMonitor
{
private readonly MobileControlTouchpanelProperties localConfig;
private IMobileControlRoomMessenger _bridge;
+ ///
+ /// Gets the CommunicationMonitor tracking the panel's online/offline state
+ ///
+ public StatusMonitorBase CommunicationMonitor { get; private set; }
+
+ private sealed class NullCommunicationMonitor : StatusMonitorBase
+ {
+ public NullCommunicationMonitor(IKeyed parent) : base(parent, 120000, 300000)
+ {
+ Status = MonitorStatus.InError;
+ Message = "Panel is not initialized";
+ }
+
+ public override void Start() { }
+ public override void Stop() { }
+ }
+
private string _appUrl;
///
@@ -128,6 +145,11 @@ namespace PepperDash.Essentials.Touchpanel
{
localConfig = config;
+ if (panel != null)
+ {
+ CommunicationMonitor = new CrestronGenericBaseCommunicationMonitor(this, panel, 120000, 300000);
+ }
+
AddPostActivationAction(SubscribeForMobileControlUpdates);
ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme);
@@ -366,6 +388,8 @@ namespace PepperDash.Essentials.Touchpanel
///
public override bool CustomActivate()
{
+ CommunicationMonitor?.Start();
+
var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this);
var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this);
@@ -393,6 +417,17 @@ namespace PepperDash.Essentials.Touchpanel
return base.CustomActivate();
}
+ ///
+ /// Stops the CommunicationMonitor on deactivation.
+ ///
+ /// True if deactivation was successful; otherwise, false.
+ public override bool Deactivate()
+ {
+ CommunicationMonitor?.Stop();
+
+ return base.Deactivate();
+ }
+
///
/// Handles device extender signal changes for system reserved signals.
///
@@ -519,15 +554,8 @@ namespace PepperDash.Essentials.Touchpanel
return false;
}) ? csIpAddress.ToString() : processorIp;
- var match = Regex.Match(url, @"^http://([^:/]+):\d+/mc/app\?token=.+$");
- if (match.Success)
- {
- string ipa = match.Groups[1].Value;
- // ip will be "192.168.1.100"
- }
-
- // replace ipa with ip but leave the rest of the string intact
- var updatedUrl = Regex.Replace(url, @"^http://[^:/]+", $"http://{ip}");
+ // replace the host but preserve whatever scheme (http/https) is already present in the URL
+ var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}");
this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl);
@@ -740,7 +768,7 @@ namespace PepperDash.Essentials.Touchpanel
///
public MobileControlTouchpanelControllerFactory()
{
- TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mcxpanel", "mcdge1000" };
+ TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mctsw1080", "mcts1080", "mcxpanel", "mcdge1000" };
MinimumEssentialsFrameworkVersion = "2.0.0";
factories = new Dictionary>
@@ -765,6 +793,8 @@ namespace PepperDash.Essentials.Touchpanel
{"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)},
{"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)},
{"ts1070", (id, controlSystem, projectName) => new Ts1070(id, controlSystem)},
+ {"tsw1080", (id, controlSystem, projectName) => new Tsw1080(id, controlSystem)},
+ {"ts1080", (id, controlSystem, projectName) => new Ts1080(id, controlSystem)},
{"dge1000", (id, controlSystem, projectName) => new Dge1000(id, controlSystem)}
};
}
diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs
index 1c9ed37a..c96dc116 100644
--- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs
+++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs
@@ -127,6 +127,16 @@ namespace PepperDash.Essentials.WebSocketServer
///
public int Port { get; private set; }
+ ///
+ /// Gets the HTTP scheme to use for generated URLs, based on whether the direct server is configured as secure
+ ///
+ private string HttpScheme => _parent.Config.DirectServer.Secure ? "https" : "http";
+
+ ///
+ /// Gets the WebSocket scheme to use for generated URLs, based on whether the direct server is configured as secure
+ ///
+ private string WsScheme => _parent.Config.DirectServer.Secure ? "wss" : "ws";
+
///
/// Gets the user app URL prefix
///
@@ -134,7 +144,8 @@ namespace PepperDash.Essentials.WebSocketServer
{
get
{
- return string.Format("http://{0}:{1}{2}?token=",
+ return string.Format("{0}://{1}:{2}{3}?token=",
+ HttpScheme,
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port,
_userAppBaseHref);
@@ -273,7 +284,7 @@ namespace PepperDash.Essentials.WebSocketServer
{
base.Initialize();
- _server = new HttpServer(Port, false);
+ _server = new HttpServer(Port, _parent.Config.DirectServer.Secure);
_server.OnGet += Server_OnGet;
@@ -291,7 +302,7 @@ namespace PepperDash.Essentials.WebSocketServer
{
ClientCertificateRequired = false,
CheckCertificateRevocation = false,
- EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11
+ EnabledSslProtocols = SslProtocols.Tls12
};
}
@@ -403,11 +414,11 @@ namespace PepperDash.Essentials.WebSocketServer
ip = csIpAddress.ToString();
}
- var appUrl = $"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}";
+ var appUrl = $"{HttpScheme}://{ip}:{Port}/mc/app?token={touchpanel.Key}";
this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key);
- touchpanel.Touchpanel.SetAppUrl($"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}");
+ touchpanel.Touchpanel.SetAppUrl(appUrl);
}
}
@@ -487,7 +498,7 @@ namespace PepperDash.Essentials.WebSocketServer
{
var config = new MobileControlApplicationConfig
{
- ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port),
+ ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, Port),
GatewayAppPath = "",
LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png",
EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false,
@@ -1098,6 +1109,7 @@ namespace PepperDash.Essentials.WebSocketServer
res.StatusCode = 200;
res.Close();
+ // remote log collector has no dedicated secure flag; keep it on http regardless of DirectServer.Secure
var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
@@ -1150,6 +1162,11 @@ namespace PepperDash.Essentials.WebSocketServer
var qp = req.QueryString;
var token = qp["token"];
+ // Each join mints a single-use clientId; the panel webview must never replay a cached
+ // response, or it reconnects forever with an already-consumed id (1008 loop).
+ res.Headers.Add("Cache-Control", "no-store");
+ res.Headers.Add("Pragma", "no-cache");
+
this.LogVerbose("Join Room Request with token: {token}", token);
byte[] body;
@@ -1213,8 +1230,7 @@ namespace PepperDash.Essentials.WebSocketServer
this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now);
// Construct WebSocket URL with clientId query parameter
- var wsProtocol = "ws";
- var wsUrl = $"{wsProtocol}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}";
+ var wsUrl = $"{WsScheme}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}";
// Construct the response object
JoinResponse jRes = new JoinResponse
@@ -1226,7 +1242,8 @@ namespace PepperDash.Essentials.WebSocketServer
Config = _parent.GetConfigWithPluginVersion(),
CodeExpires = new DateTime().AddYears(1),
UserCode = bridge.UserCode,
- UserAppUrl = string.Format("http://{0}:{1}/mc/app",
+ UserAppUrl = string.Format("{0}://{1}:{2}/mc/app",
+ HttpScheme,
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port),
WebSocketUrl = wsUrl,
@@ -1251,6 +1268,8 @@ namespace PepperDash.Essentials.WebSocketServer
{
res.StatusCode = 200;
res.ContentType = "application/json";
+ res.Headers.Add("Cache-Control", "no-store");
+ res.Headers.Add("Pragma", "no-cache");
var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion };
var message = JsonConvert.SerializeObject(version);
this.LogVerbose("{message}", message);
diff --git a/src/PepperDash.Essentials/PepperDash.Essentials.csproj b/src/PepperDash.Essentials/PepperDash.Essentials.csproj
index 20a42ffd..cb83bf84 100644
--- a/src/PepperDash.Essentials/PepperDash.Essentials.csproj
+++ b/src/PepperDash.Essentials/PepperDash.Essentials.csproj
@@ -48,7 +48,7 @@
-
+