diff --git a/README.md b/README.md
index b485eade..0ad097ca 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,38 @@ 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/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs
index eeba5772..cfbf5785 100644
--- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs
+++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs
@@ -72,6 +72,20 @@ namespace PepperDash.Core
///
public bool IsRunning { get => _httpsServer?.IsListening ?? false; }
+ ///
+ /// Gets a value indicating whether there are active WebSocket connections.
+ ///
+ public bool HasActiveConnections
+ {
+ get
+ {
+ if (_httpsServer == null || !_httpsServer.IsListening) return false;
+ var service = _httpsServer.WebSocketServices[_path];
+ if (service == null) return false;
+ return service.Sessions.Count > 0;
+ }
+ }
+
private readonly ITextFormatter _textFormatter;
@@ -217,6 +231,8 @@ namespace PepperDash.Core
{
Debug.LogInformation("Starting Websocket Server on port: {0}", port);
+
+
Start(port, CertPath, _certificatePassword);
}
diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs
index 6cd3835e..3e683ac5 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/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs
index 887f1789..8603d5b6 100644
--- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs
+++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs
@@ -1,10 +1,12 @@
+using System;
using PepperDash.Core;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
///
- /// Defines the contract for IMobileControlMessenger
+ /// Obsolete: messengers are subscription based by default; use IMobileControlMessenger instead.
///
+ [Obsolete("This interface is obsolete and will be removed in a future version. All messengers are now subscription based.")]
public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger
{
///
diff --git a/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs b/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs
index ee20a22a..3db61ebd 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;
+ return _partitionSensor?.PartitionPresentFeedback?.BoolValue ?? _partitionPresent;
}
return _partitionPresent;
diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs
index a45adb27..7ca54c7a 100644
--- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs
+++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs
@@ -5,6 +5,7 @@ using Serilog.Events;
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -17,7 +18,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, IEssentialsRoomCombiner
+ public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombinerWithOperationStatus
{
private EssentialsRoomCombinerPropertiesConfig _propertiesConfig;
@@ -75,10 +76,29 @@ 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.
@@ -105,6 +125,12 @@ 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
@@ -133,6 +159,8 @@ namespace PepperDash.Essentials.Core
// connected and initialized
DeviceManager.AllDevicesInitialized += (o, a) =>
{
+ InitializeOperationStatusProviders();
+
if (IsInAutoMode)
{
DetermineRoomCombinationScenario();
@@ -256,13 +284,19 @@ namespace PepperDash.Essentials.Core
private async Task ChangeScenario(IRoomCombinationScenario newScenario)
{
-
+ if (newScenario == _currentScenario)
+ {
+ return;
+ }
- if (newScenario == _currentScenario)
- {
- return;
- }
+ var operationId = SetCombinationOperationStatus(
+ CombinationOperationState.InProgress,
+ newScenario != null ? newScenario.Key : null,
+ null,
+ true);
+ try
+ {
// Deactivate the old scenario first
if (_currentScenario != null)
{
@@ -281,7 +315,20 @@ 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
@@ -293,6 +340,11 @@ 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.
///
@@ -304,6 +356,20 @@ 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
///
@@ -455,6 +521,299 @@ 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 868f3992..4a7328a9 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 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,11 +49,18 @@ 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; }
}
///
@@ -61,14 +68,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; }
@@ -91,26 +98,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; }
@@ -121,14 +128,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; }
@@ -139,14 +146,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 34d0a0a0..641993ea 100644
--- a/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs
+++ b/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs
@@ -16,12 +16,14 @@ 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
///
@@ -85,6 +87,56 @@ 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 382f5d55..4998a179 100644
--- a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs
+++ b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Crestron.SimplSharp;
using PepperDash.Core;
+using PepperDash.Core.Logging;
using Serilog.Events;
namespace PepperDash.Essentials.Core
@@ -296,19 +297,14 @@ namespace PepperDash.Essentials.Core
{
var secretPresent = provider.TestSecret(key);
- Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
+ provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
if (!secretPresent)
return
- String.Format(
- "Unable to update secret for {0}:{1} - Please use the 'SetSecret' command to modify it");
+ $"Unable to update secret for {provider.Key}:{key} - Please use the 'SetSecret' command to modify it";
var response = provider.SetSecret(key, secret)
- ? String.Format(
- "Secret successfully set for {0}:{1}",
- provider.Key, key)
- : String.Format(
- "Unable to set secret for {0}:{1}",
- provider.Key, key);
+ ? $"Secret successfully set for {provider.Key}:{key}"
+ : $"Unable to set secret for {provider.Key}:{key}";
return response;
}
@@ -316,19 +312,14 @@ namespace PepperDash.Essentials.Core
{
var secretPresent = provider.TestSecret(key);
- Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
+ provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
if (secretPresent)
return
- String.Format(
- "Unable to set secret for {0}:{1} - Please use the 'UpdateSecret' command to modify it");
+ $"Unable to set secret for {provider.Key}:{key} - Please use the 'UpdateSecret' command to modify it";
var response = provider.SetSecret(key, secret)
- ? String.Format(
- "Secret successfully set for {0}:{1}",
- provider.Key, key)
- : String.Format(
- "Unable to set secret for {0}:{1}",
- provider.Key, key);
+ ? $"Secret successfully set for {provider.Key}:{key}"
+ : $"Unable to set secret for {provider.Key}:{key}";
return response;
}
@@ -377,15 +368,9 @@ namespace PepperDash.Essentials.Core
var key = args[1];
-
- provider.SetSecret(key, "");
response = provider.SetSecret(key, "")
- ? String.Format(
- "Secret successfully deleted for {0}:{1}",
- provider.Key, key)
- : String.Format(
- "Unable to delete secret for {0}:{1}",
- provider.Key, key);
+ ? $"Secret successfully deleted for {provider.Key}:{key}"
+ : $"Unable to delete secret for {provider.Key}:{key}";
CrestronConsole.ConsoleCommandResponse(response);
return;
diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs
index 56c983ae..b258632f 100644
--- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs
+++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs
@@ -17,7 +17,10 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
/// Represents a DebugSessionRequestHandler
///
public class DebugSessionRequestHandler : WebApiBaseRequestHandler
- {
+ {
+ private CTimer _portForwardTimeoutTimer;
+ private readonly object _timerLock = new object();
+
///
/// Constructor
///
@@ -48,6 +51,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
var port = 0;
+ string csIp = null;
if (!Debug.WebsocketSink.IsRunning)
{
@@ -57,15 +61,18 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
// Start the WS Server
Debug.WebsocketSink.StartServerAndSetPort(port);
Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose);
+ }
- // Attempt to forward the port to the CS LAN
- try
+ // Attempt to get the CS LAN IP and forward the port
+ try
+ {
+ var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
+ EthernetAdapterType.EthernetCSAdapter);
+ csIp = CrestronEthernetHelper.GetEthernetParameter(
+ CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
+
+ if (port > 0)
{
- var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
- EthernetAdapterType.EthernetCSAdapter);
- var csIp = CrestronEthernetHelper.GetEthernetParameter(
- CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
-
var result = CrestronEthernetHelper.AddPortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
@@ -77,26 +84,29 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
else
{
Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port);
+ StartPortForwardTimeout(port, csIp);
}
}
- catch (ArgumentException)
- {
- Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding");
- }
- catch (Exception ex)
- {
- Debug.LogMessage(LogEventLevel.Warning, "Error automatically forwarding debug websocket port to CS LAN: {0}", ex.Message);
- }
+ }
+ catch (ArgumentException)
+ {
+ Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding");
+ }
+ catch (Exception ex)
+ {
+ Debug.LogMessage(LogEventLevel.Warning, "Error automatically forwarding debug websocket port to CS LAN: {0}", ex.Message);
}
var url = Debug.WebsocketSink.Url;
- object data = new
+ var data = new
{
- url = Debug.WebsocketSink.Url
+ url = Debug.WebsocketSink.Url,
+ fallbackUrl = csIp != null ? url.Replace(csIp, ip) : null
};
Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url);
+ Debug.LogMessage(LogEventLevel.Information, "Fallback Debug Session URL: {0}", data.fallbackUrl);
// Return the port number with the full url of the WS Server
var res = JsonConvert.SerializeObject(data);
@@ -120,6 +130,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
///
protected override void HandlePost(HttpCwsContext context)
{
+ CancelPortForwardTimeout();
+
var port = Debug.WebsocketSink.Port;
Debug.WebsocketSink.StopServer();
@@ -132,17 +144,24 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
var csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
- var result = CrestronEthernetHelper.RemovePortForwarding(
- (ushort)port, (ushort)port, csIp,
- CrestronEthernetHelper.ePortMapTransport.TCP);
-
- if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
+ if (port <= 0)
{
- Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding for debug websocket: {0}", result);
+ Debug.LogMessage(LogEventLevel.Debug, "Debug websocket port is not set; skipping port forwarding removal");
}
else
{
- Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed", port);
+ var result = CrestronEthernetHelper.RemovePortForwarding(
+ (ushort)port, (ushort)port, csIp,
+ CrestronEthernetHelper.ePortMapTransport.TCP);
+
+ if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
+ {
+ Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding for debug websocket: {0}", result);
+ }
+ else
+ {
+ Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed", port);
+ }
}
}
catch (ArgumentException)
@@ -161,5 +180,56 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped");
}
+ private void StartPortForwardTimeout(int port, string csIp)
+ {
+ lock (_timerLock)
+ {
+ _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;
+ }
+
+ Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port);
+
+ try
+ {
+ var result = CrestronEthernetHelper.RemovePortForwarding(
+ (ushort)port, (ushort)port, csIp,
+ CrestronEthernetHelper.ePortMapTransport.TCP);
+
+ if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
+ {
+ Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", result);
+ }
+ else
+ {
+ Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed due to timeout", port);
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", ex.Message);
+ }
+ }, 30000);
+ }
+ }
+
+ ///
+ /// Cancels the port forward timeout timer if a session is being explicitly stopped.
+ ///
+ private void CancelPortForwardTimeout()
+ {
+ lock (_timerLock)
+ {
+ _portForwardTimeoutTimer?.Dispose();
+ _portForwardTimeoutTimer = null;
+ }
+ }
+
}
}
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