From 6f2231a8d414c6f7a1a412afb5bb56c25dcceac8 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 11:28:39 -0600 Subject: [PATCH 01/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DebugSessionRequestHandler.cs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index 56c983ae..1eeb2780 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -132,17 +132,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) From e4cd3617a1f5049e247c28d8c43bac705a4f56d8 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 14:29:49 -0600 Subject: [PATCH 02/15] fix: add csIp handling and update debug session URL in DebugSessionRequestHandler --- .../Web/RequestHandlers/DebugSessionRequestHandler.cs | 6 ++++-- .../Messengers/MessengerBase.cs | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index 1eeb2780..e6044144 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -48,6 +48,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) { @@ -63,7 +64,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers { var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType( EthernetAdapterType.EthernetCSAdapter); - var csIp = CrestronEthernetHelper.GetEthernetParameter( + csIp = CrestronEthernetHelper.GetEthernetParameter( CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); var result = CrestronEthernetHelper.AddPortForwarding( @@ -93,7 +94,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers object data = new { - url = Debug.WebsocketSink.Url + url = Debug.WebsocketSink.Url, + csLanUrl = csIp != null ? url.Replace(ip, csIp) : null }; Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url); diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs index 3031f4ba..eb3afec3 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs @@ -264,6 +264,9 @@ namespace PepperDash.Essentials.AppServer.Messengers message.Name = _device.Name; + message.MessageBasePath = MessagePath; + + var token = JToken.FromObject(message); PostStatusMessage(token, MessagePath, clientId); From 7ff4f29c11b98642080b63e5fdffd9e60f0de40e Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 14:52:35 -0600 Subject: [PATCH 03/15] fix: improve CS LAN IP handling and update fallback debug session URL in DebugSessionRequestHandler --- .../DebugSessionRequestHandler.cs | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index e6044144..d1d27194 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -58,15 +58,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); - 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); @@ -80,25 +83,26 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port); } } - 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, - csLanUrl = csIp != null ? url.Replace(ip, csIp) : null + 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); From ee971c4ce126d5cac794fa3688557b68ff367d6f Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 15:08:34 -0600 Subject: [PATCH 04/15] fix: add port forward timeout handling in DebugSessionRequestHandler --- .../Logging/DebugWebsocketSink.cs | 16 +++++ .../DebugSessionRequestHandler.cs | 58 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) 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/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index d1d27194..59c662dc 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 /// @@ -81,6 +84,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers else { Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port); + StartPortForwardTimeout(port, csIp); } } } @@ -126,6 +130,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// protected override void HandlePost(HttpCwsContext context) { + CancelPortForwardTimeout(); + var port = Debug.WebsocketSink.Port; Debug.WebsocketSink.StopServer(); @@ -174,5 +180,55 @@ 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"); + 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; + } + } + } } From 0896327dfe2567858a5fde020744111faab79be4 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 05/15] Initial plan From 8a9c8b55357a87338a03e1fade4fb3f4e0fdabf4 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:11:48 +0000 Subject: [PATCH 06/15] Mark IMobileControlMessengerWithSubscriptions and EnableMessengerSubscriptions as obsolete All messengers are now subscription based in v3.x, making these constructs no longer necessary. Closes #1435 Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/bda64c9c-5343-412b-801f-5e60816bc38d Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com> --- .../IMobileControlMessengerWithSubscriptions.cs | 2 ++ .../MobileControlConfig.cs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs index 887f1789..e6365571 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs @@ -1,3 +1,4 @@ +using System; using PepperDash.Core; namespace PepperDash.Essentials.Core.DeviceTypeInterfaces @@ -5,6 +6,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces /// /// Defines the contract for IMobileControlMessenger /// + [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.MobileControl/MobileControlConfig.cs b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs index ec7219a3..9beed963 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -43,6 +44,7 @@ namespace PepperDash.Essentials /// Enable subscriptions for Messengers /// [JsonProperty("enableMessengerSubscriptions")] + [Obsolete("This property is obsolete and will be removed in a future version. All messengers are now subscription based.")] public bool EnableMessengerSubscriptions { get; set; } } From 493cd91daadb53a1fe665b6f734350b8340fe644 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 26 Jun 2026 14:32:22 -0600 Subject: [PATCH 07/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../IMobileControlMessengerWithSubscriptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs index e6365571..8603d5b6 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs @@ -4,7 +4,7 @@ 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 From 13a4f8101e8339db044ff52f952221910c210c76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:55:49 +0000 Subject: [PATCH 08/15] Update XML summary for EnableMessengerSubscriptions to reflect obsolete status --- src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs index 9beed963..27c9d31e 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs @@ -41,7 +41,7 @@ namespace PepperDash.Essentials public bool EnableApiServer { get; set; } = true; /// - /// Enable subscriptions for Messengers + /// Retained for backward compatibility only. This property is obsolete; all messengers are now subscription based. /// [JsonProperty("enableMessengerSubscriptions")] [Obsolete("This property is obsolete and will be removed in a future version. All messengers are now subscription based.")] From 9eacc508c23038de38d0422342d793f62de2dff5 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 26 Jun 2026 15:57:52 -0600 Subject: [PATCH 09/15] Clarify summary for EnableMessengerSubscriptions property Updated the summary comment for EnableMessengerSubscriptions property to clarify its purpose. --- .../MobileControlConfig.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs index 27c9d31e..963e7fd5 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs @@ -41,7 +41,7 @@ namespace PepperDash.Essentials public bool EnableApiServer { get; set; } = true; /// - /// Retained for backward compatibility only. This property is obsolete; all messengers are now subscription based. + /// Enables subscriptions for messengers /// [JsonProperty("enableMessengerSubscriptions")] [Obsolete("This property is obsolete and will be removed in a future version. All messengers are now subscription based.")] @@ -290,4 +290,4 @@ namespace PepperDash.Essentials /// NEO } -} \ No newline at end of file +} From fb424ed5ad51cd9c721cec61af27e900a73b0073 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 10/15] refactor: marked mobile control subscription items as obsolete Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com> From f85343d8fd7b6cb70952a74ee8d03a974da6ae4b Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Thu, 2 Jul 2026 15:06:51 -0500 Subject: [PATCH 11/15] fix: string formatting for console responses was incorrect in some cases and causing exceptions --- .../Secrets/SecretsManager.cs | 36 ++++++------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs index 382f5d55..ac4e80d8 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,10 @@ 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; From e623865c2aa1425e2f9e4fb27d98f61064cb3429 Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 7 Jul 2026 10:51:28 -0500 Subject: [PATCH 12/15] 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 a45adb27..937fffd8 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 @@ namespace PepperDash.Essentials.Core 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 @@ namespace PepperDash.Essentials.Core 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 @@ namespace PepperDash.Essentials.Core 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 @@ 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 +337,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 +502,53 @@ namespace PepperDash.Essentials.Core } #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 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.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs index 966d8d77..65a279f2 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/IEssentialsRoomCombinerMessenger.cs @@ -21,6 +21,8 @@ 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. @@ -35,6 +37,7 @@ namespace PepperDash.Essentials.AppServer.Messengers : base(key, messagePath, roomCombiner as IKeyName) { _roomCombiner = roomCombiner; + _roomCombinerWithOperationStatus = roomCombiner as IEssentialsRoomCombinerWithOperationStatus; } /// @@ -98,6 +101,19 @@ 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 @@ -138,6 +154,7 @@ 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 @@ -194,6 +211,12 @@ 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. /// From 8e9dbb6f44faf570102cd1c7899219b2999a90cd Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 7 Jul 2026 10:54:59 -0500 Subject: [PATCH 13/15] 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 937fffd8..ed164eb7 100644 --- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs +++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombiner.cs @@ -75,8 +75,14 @@ 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(); @@ -112,6 +118,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 @@ -268,7 +280,7 @@ namespace PepperDash.Essentials.Core return; } - SetCombinationOperationStatus( + var operationId = SetCombinationOperationStatus( CombinationOperationState.InProgress, newScenario != null ? newScenario.Key : null, null, @@ -294,21 +306,21 @@ namespace PepperDash.Essentials.Core 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 @@ namespace PepperDash.Essentials.Core #endregion - private void SetCombinationOperationStatus( + private string SetCombinationOperationStatus( CombinationOperationState state, string scenarioKey, string message, bool resetStartedUtc) { + string operationId; + lock (_combinationOperationLock) { if (resetStartedUtc) @@ -528,9 +542,91 @@ namespace PepperDash.Essentials.Core _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) diff --git a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs index 868f3992..13287c72 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 @@ 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; } From b4990974484d823036d6c5bfe021cdc1164715e2 Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 7 Jul 2026 11:09:59 -0500 Subject: [PATCH 14/15] 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 ed164eb7..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; @@ -87,6 +88,12 @@ namespace PepperDash.Essentials.Core 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 @@ namespace PepperDash.Essentials.Core // connected and initialized DeviceManager.AllDevicesInitialized += (o, a) => { + InitializeOperationStatusProviders(); + if (IsInAutoMode) { DetermineRoomCombinationScenario(); @@ -306,11 +315,9 @@ namespace PepperDash.Essentials.Core 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 @@ namespace PepperDash.Essentials.Core _combinationOperation.Message = message; } + if (state == CombinationOperationState.InProgress) + { + _pendingCompletionOperationId = null; + _pendingCompletionScenarioKey = null; + } + operationId = _combinationOperation.OperationId; } @@ -583,6 +596,8 @@ namespace PepperDash.Essentials.Core _combinationOperation.ScenarioKey = scenarioKey ?? _combinationOperation.ScenarioKey; _combinationOperation.State = state; _combinationOperation.Message = message; + _pendingCompletionOperationId = null; + _pendingCompletionScenarioKey = null; statusUpdated = true; } @@ -629,6 +644,160 @@ namespace PepperDash.Essentials.Core "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 707195b8c5544b449846ae7da2475da5b3997c03 Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 14 Jul 2026 19:48:32 -0500 Subject: [PATCH 15/15] 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 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;