diff --git a/README.md b/README.md index 0ad097ca..b485eade 100644 --- a/README.md +++ b/README.md @@ -54,38 +54,6 @@ Utilization of Essentials Framework falls into the following categories: For detailed documentation, see the [Wiki](https://github.com/PepperDash/EssentialsFramework/wiki). -## Room Combiner Operation Lifecycle - -Essentials room combiner now exposes an operation lifecycle that clients can use to show progress and terminal status during scenario changes. - -Lifecycle states: -- Idle -- InProgress -- Completed -- Failed -- TimedOut - -The lifecycle is exposed on room combiners that implement the operation-status extension and is included in app server combiner messages under the combinationOperation payload. - -Configuration: -- combinationOperationTimeoutSeconds (optional): timeout in seconds for an in-progress operation. -- Default when omitted: 300 seconds. -- Values less than or equal to 0 are treated as invalid and fall back to the default. - -Example room combiner properties: - -```json -{ - "defaultToManualMode": false, - "defaultScenarioKey": "divided", - "combinationOperationTimeoutSeconds": 30, - "roomKeys": ["roomA", "roomB"] -} -``` - -Operational note: -- When an operation reaches a terminal state (Completed, Failed, TimedOut), the operation timeout timer is stopped. - ## Support * Check out our [Discord Server](https://discord.gg/rWyeRH3K) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ab9f2732..235fee48 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 2.36.6-local + 2.42.1-local $(Version) PepperDash Technology PepperDash Technology 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/DeviceTypeInterfaces/IHasWirelessSharing.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs index 3e683ac5..6cd3835e 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs @@ -1,5 +1,5 @@ using System; -using PepperDash.Core; + namespace PepperDash.Essentials.Core.DeviceTypeInterfaces { /// 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 4a7328a9..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 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/Secrets/SecretsManager.cs b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs index 4998a179..ac4e80d8 100644 --- a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs +++ b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs @@ -368,6 +368,7 @@ namespace PepperDash.Essentials.Core var key = args[1]; + provider.SetSecret(key, ""); response = provider.SetSecret(key, "") ? $"Secret successfully deleted for {provider.Key}:{key}" : $"Unable to delete secret for {provider.Key}:{key}"; diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index b258632f..59c662dc 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -187,12 +187,11 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers _portForwardTimeoutTimer?.Dispose(); _portForwardTimeoutTimer = new CTimer(_ => { - if (Debug.WebsocketSink.HasActiveConnections) - { - Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward"); - StartPortForwardTimeout(port, csIp); - return; - } + if (Debug.WebsocketSink.HasActiveConnections) + { + Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward"); + return; + } Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port); 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/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/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 @@ - +