From f7a44df69f8e71d46f381fac087997b76b3eb5d0 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:09:32 +0000 Subject: [PATCH 01/11] Initial plan From 23ffc83dea463447a551b0fdcd1b9723bcdf9cd3 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:32:56 +0000 Subject: [PATCH 02/11] refactor: marked mobile control subscription items as obsolete Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com> From 4e8514c6cc96989a14248632b99868f2bd4ba3b7 Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 7 Jul 2026 10:51:28 -0500 Subject: [PATCH 03/11] feat: add optional room combiner operation status lifecycle --- .../Room/Combining/EssentialsRoomCombiner.cs | 108 ++++++++++++++++-- .../Room/Combining/IEssentialsRoomCombiner.cs | 52 +++++++++ .../IEssentialsRoomCombinerMessenger.cs | 23 ++++ 3 files changed, 176 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs index a45adb279..937fffd89 100644 --- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs +++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs @@ -17,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, IEssentialsRoomCombiner + public class EssentialsRoomCombiner : EssentialsDevice, IEssentialsRoomCombinerWithOperationStatus { private EssentialsRoomCombinerPropertiesConfig _propertiesConfig; @@ -79,6 +79,13 @@ public bool DisableAutoMode private Mutex _scenarioChange = new Mutex(); + private readonly object _combinationOperationLock = new object(); + + private CombinationOperationStatus _combinationOperation = new CombinationOperationStatus + { + State = CombinationOperationState.Idle + }; + /// /// Initializes a new instance of the class, which manages room combination /// scenarios and partition states. @@ -256,13 +263,19 @@ private void DetermineRoomCombinationScenario() private async Task ChangeScenario(IRoomCombinationScenario newScenario) { - + if (newScenario == _currentScenario) + { + return; + } - if (newScenario == _currentScenario) - { - return; - } + SetCombinationOperationStatus( + CombinationOperationState.InProgress, + newScenario != null ? newScenario.Key : null, + null, + true); + try + { // Deactivate the old scenario first if (_currentScenario != null) { @@ -281,7 +294,22 @@ private async Task ChangeScenario(IRoomCombinationScenario newScenario) RoomCombinationScenarioChanged?.Invoke(this, new EventArgs()); - + SetCombinationOperationStatus( + CombinationOperationState.Completed, + _currentScenario != null ? _currentScenario.Key : null, + null, + false); + } + catch (Exception ex) + { + this.LogException(ex, "Error changing room combination scenario"); + + SetCombinationOperationStatus( + CombinationOperationState.Failed, + newScenario != null ? newScenario.Key : null, + "Combination operation failed", + false); + } } #region IEssentialsRoomCombiner Members @@ -293,6 +321,11 @@ private async Task ChangeScenario(IRoomCombinationScenario newScenario) /// 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 +337,20 @@ public IRoomCombinationScenario CurrentScenario } } + /// + /// Gets the current room combination operation status. + /// + public CombinationOperationStatus CombinationOperation + { + get + { + lock (_combinationOperationLock) + { + return CloneCombinationOperationStatus(_combinationOperation); + } + } + } + /// /// Gets or sets the IsInAutoModeFeedback /// @@ -455,6 +502,53 @@ public void SetRoomCombinationScenario(string scenarioKey) } #endregion + + private void SetCombinationOperationStatus( + CombinationOperationState state, + string scenarioKey, + string message, + bool resetStartedUtc) + { + 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; + } + } + + CombinationOperationStatusChanged?.Invoke(this, EventArgs.Empty); + } + + 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/IEssentialsRoomCombiner.cs b/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs index 34d0a0a04..641993ea0 100644 --- a/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs +++ b/src/PepperDash.Essentials.Core/Room/Combining/IEssentialsRoomCombiner.cs @@ -16,12 +16,14 @@ public interface IEssentialsRoomCombiner : IKeyed /// 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 @@ public interface IEssentialsRoomCombiner : IKeyed 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.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs index 966d8d773..65a279f21 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs @@ -21,6 +21,8 @@ public class IEssentialsRoomCombinerMessenger : MessengerBase { private readonly IEssentialsRoomCombiner _roomCombiner; + private readonly IEssentialsRoomCombinerWithOperationStatus _roomCombinerWithOperationStatus; + /// /// Initializes a new instance of the class, which facilitates /// messaging for an instance. @@ -35,6 +37,7 @@ public IEssentialsRoomCombinerMessenger(string key, string messagePath, IEssenti : base(key, messagePath, roomCombiner as IKeyName) { _roomCombiner = roomCombiner; + _roomCombinerWithOperationStatus = roomCombiner as IEssentialsRoomCombinerWithOperationStatus; } /// @@ -98,6 +101,19 @@ protected override void RegisterActions() 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 @@ -138,6 +154,7 @@ private void SendFullStatus(string id = null) DisableAutoMode = _roomCombiner.DisableAutoMode, IsInAutoMode = _roomCombiner.IsInAutoMode, CurrentScenario = _roomCombiner.CurrentScenario, + CombinationOperation = _roomCombinerWithOperationStatus != null ? _roomCombinerWithOperationStatus.CombinationOperation : null, Rooms = rooms, RoomCombinationScenarios = _roomCombiner.RoomCombinationScenarios, Partitions = _roomCombiner.Partitions @@ -194,6 +211,12 @@ public class IEssentialsRoomCombinerStateMessage : DeviceStateMessageBase [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. /// From 35b05f2631b3337b9793f88486fec0c1db5db36d Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 7 Jul 2026 10:54:59 -0500 Subject: [PATCH 04/11] feat: add optional room combiner operation timeout setting --- .../Room/Combining/EssentialsRoomCombiner.cs | 112 ++++++++++++++++-- .../EssentialsRoomCombinerPropertiesConfig.cs | 63 +++++----- 2 files changed, 139 insertions(+), 36 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs index 937fffd89..ed164eb77 100644 --- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs +++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs @@ -75,8 +75,14 @@ public bool DisableAutoMode 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(); @@ -112,6 +118,12 @@ public EssentialsRoomCombiner(string key, EssentialsRoomCombinerPropertiesConfig _scenarioChangeDebounceTimeSeconds = _propertiesConfig.ScenarioChangeDebounceTimeSeconds; } + if (_propertiesConfig.CombinationOperationTimeoutSeconds.HasValue + && _propertiesConfig.CombinationOperationTimeoutSeconds.Value > 0) + { + _combinationOperationTimeoutSeconds = _propertiesConfig.CombinationOperationTimeoutSeconds.Value; + } + IsInAutoModeFeedback = new BoolFeedback(() => _isInAutoMode); // default to auto mode @@ -268,7 +280,7 @@ private async Task ChangeScenario(IRoomCombinationScenario newScenario) return; } - SetCombinationOperationStatus( + var operationId = SetCombinationOperationStatus( CombinationOperationState.InProgress, newScenario != null ? newScenario.Key : null, null, @@ -294,21 +306,21 @@ private async Task ChangeScenario(IRoomCombinationScenario newScenario) RoomCombinationScenarioChanged?.Invoke(this, new EventArgs()); - SetCombinationOperationStatus( + TrySetCombinationOperationTerminalStatus( + operationId, CombinationOperationState.Completed, _currentScenario != null ? _currentScenario.Key : null, - null, - false); + null); } catch (Exception ex) { this.LogException(ex, "Error changing room combination scenario"); - SetCombinationOperationStatus( + TrySetCombinationOperationTerminalStatus( + operationId, CombinationOperationState.Failed, newScenario != null ? newScenario.Key : null, - "Combination operation failed", - false); + "Combination operation failed"); } } @@ -503,12 +515,14 @@ public void SetRoomCombinationScenario(string scenarioKey) #endregion - private void SetCombinationOperationStatus( + private string SetCombinationOperationStatus( CombinationOperationState state, string scenarioKey, string message, bool resetStartedUtc) { + string operationId; + lock (_combinationOperationLock) { if (resetStartedUtc) @@ -528,11 +542,93 @@ private void SetCombinationOperationStatus( _combinationOperation.State = state; _combinationOperation.Message = message; } + + 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; + 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 static CombinationOperationStatus CloneCombinationOperationStatus(CombinationOperationStatus status) { if (status == null) diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs index 868f3992d..13287c724 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,11 +49,18 @@ public class EssentialsRoomCombinerPropertiesConfig [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 @@ public class EssentialsRoomCombinerPropertiesConfig /// 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 @@ public class PartitionConfig : IKeyName /// 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 @@ public class RoomCombinationScenarioConfig : IKeyName [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 @@ public class RoomCombinationScenarioConfig : IKeyName /// 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; } From 0ec6de3d66783f9ef40e1d38aa69020e9e6589b0 Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 7 Jul 2026 11:09:59 -0500 Subject: [PATCH 05/11] feat: gate combiner completion on provider reconciliation --- .../Room/Combining/EssentialsRoomCombiner.cs | 177 +++++++++++++++++- 1 file changed, 173 insertions(+), 4 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs index ed164eb77..7ca54c7a2 100644 --- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs +++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -87,6 +88,12 @@ public bool DisableAutoMode 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 @@ -152,6 +159,8 @@ public EssentialsRoomCombiner(string key, EssentialsRoomCombinerPropertiesConfig // connected and initialized DeviceManager.AllDevicesInitialized += (o, a) => { + InitializeOperationStatusProviders(); + if (IsInAutoMode) { DetermineRoomCombinationScenario(); @@ -306,11 +315,9 @@ private async Task ChangeScenario(IRoomCombinationScenario newScenario) RoomCombinationScenarioChanged?.Invoke(this, new EventArgs()); - TrySetCombinationOperationTerminalStatus( + TryCompleteCombinationOperationIfReady( operationId, - CombinationOperationState.Completed, - _currentScenario != null ? _currentScenario.Key : null, - null); + _currentScenario != null ? _currentScenario.Key : null); } catch (Exception ex) { @@ -543,6 +550,12 @@ private string SetCombinationOperationStatus( _combinationOperation.Message = message; } + if (state == CombinationOperationState.InProgress) + { + _pendingCompletionOperationId = null; + _pendingCompletionScenarioKey = null; + } + operationId = _combinationOperation.OperationId; } @@ -583,6 +596,8 @@ private void TrySetCombinationOperationTerminalStatus( _combinationOperation.ScenarioKey = scenarioKey ?? _combinationOperation.ScenarioKey; _combinationOperation.State = state; _combinationOperation.Message = message; + _pendingCompletionOperationId = null; + _pendingCompletionScenarioKey = null; statusUpdated = true; } @@ -629,6 +644,160 @@ private void HandleCombinationOperationTimeout(string operationId) "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) From 5a30461eaf788856eb3af5ecc65d8c5c82bf9d37 Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 14 Jul 2026 19:48:32 -0500 Subject: [PATCH 06/11] fix: guard PartitionPresent getter against null sensor feedback EssentialsPartitionController.PartitionPresent threw a NullReferenceException in Auto mode when the partition sensor's PartitionPresentFeedback was not yet initialized, breaking combiner fullStatus serialization to MC clients. Fall back to the last-known _partitionPresent value instead of dereferencing a null sensor feedback. --- .../PartitionSensor/EssentialsPartitionController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs b/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs index ee20a22aa..3db61ebd3 100644 --- a/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs +++ b/src/PepperDash.Essentials.Core/PartitionSensor/EssentialsPartitionController.cs @@ -30,7 +30,7 @@ public bool PartitionPresent { if (IsInAutoMode) { - return _partitionSensor.PartitionPresentFeedback.BoolValue; + return _partitionSensor?.PartitionPresentFeedback?.BoolValue ?? _partitionPresent; } return _partitionPresent; From eaaf5ecce50b73da4e34367f967c4751b7d8c208 Mon Sep 17 00:00:00 2001 From: equinoy Date: Wed, 15 Jul 2026 09:52:07 -0500 Subject: [PATCH 07/11] docs(readme): document room combiner operation lifecycle and combinationOperationTimeoutSeconds default behavior --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index b485eade1..0ad097ca3 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) From 528373aa61064f12587f34d450c739794767f644 Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:36:09 -0500 Subject: [PATCH 08/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs index 13287c724..4a7328a99 100644 --- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs +++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs @@ -13,7 +13,7 @@ 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, + /// 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")] From 2bc3e323017324b0ee3e33da90a20cd466b521fb Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:36:44 -0500 Subject: [PATCH 09/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DeviceTypeInterfaces/IHasWirelessSharing.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs index 6cd3835e6..3e683ac52 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 { /// From 3e9f32c42ed29f8de55f2b5d6a64da27271b2ead Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:37:15 -0500 Subject: [PATCH 10/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Web/RequestHandlers/DebugSessionRequestHandler.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index 59c662dc2..b258632f6 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -187,11 +187,12 @@ private void StartPortForwardTimeout(int port, string csIp) _portForwardTimeoutTimer?.Dispose(); _portForwardTimeoutTimer = new CTimer(_ => { - if (Debug.WebsocketSink.HasActiveConnections) - { - Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward"); - return; - } + 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); From 690db6beb81c34bd44891ec99dba191a39647c80 Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:37:28 -0500 Subject: [PATCH 11/11] fix: potential fix for pull request finding --- src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs index ac4e80d84..4998a1795 100644 --- a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs +++ b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs @@ -368,7 +368,6 @@ private static void DeleteSecretProcess(string cmd) 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}";