From 8817d70f0793ee804747940da0f195e7cddff580 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 20 Mar 2026 12:03:00 -0600 Subject: [PATCH 01/33] feat: add interfaces for VLAN and PoE management in network switches --- .../INetworkSwitchControl.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs new file mode 100644 index 000000000..4ad6094dd --- /dev/null +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -0,0 +1,48 @@ +namespace PepperDash.Essentials.Core.DeviceTypeInterfaces +{ + /// + /// Interface for network switches that support VLAN assignment on individual ports. + /// + public interface INetworkSwitchVlanManager + { + /// + /// Returns the current access VLAN ID configured on the port. + /// Return -1 when the value is unavailable (e.g. the switch has not been polled yet + /// or the implementation does not support VLAN queries). + /// + /// Switch port identifier + /// VLAN ID or -1 when unavailable + int GetPortCurrentVlan(string port); + + /// + /// Changes the access VLAN of a single switch port. + /// The implementation is responsible for entering/exiting privileged/config mode. + /// + /// Switch port identifier (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco) + /// Target VLAN ID (1-4093) + void SetPortVlan(string port, uint vlanId); + } + + /// + /// Interface for network switches that support Power over Ethernet (PoE) control on individual ports. + /// + public interface INetworkSwitchPoeManager + { + /// + /// Enables or disables PoE power delivery on a single switch port. + /// The implementation is responsible for entering/exiting privileged/config mode. + /// + /// Switch port identifier + /// True to enable PoE; false to disable PoE + void SetPortPoeState(string port, bool enabled); + } + + /// + /// Standardized interface for network switch devices that support per-port PoE control + /// and VLAN assignment. + /// + public interface INetworkSwitchPoeVlanManager : INetworkSwitchVlanManager, INetworkSwitchPoeManager + { + + } +} From 76311b83ff866fce29ad6768855ab8eb8dbabeba Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 20 Mar 2026 13:25:58 -0600 Subject: [PATCH 02/33] feat: add event and arguments for port state changes in network switch management --- .../INetworkSwitchControl.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 4ad6094dd..6174ff282 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -1,3 +1,5 @@ +using System; + namespace PepperDash.Essentials.Core.DeviceTypeInterfaces { /// @@ -43,6 +45,58 @@ public interface INetworkSwitchPoeManager /// public interface INetworkSwitchPoeVlanManager : INetworkSwitchVlanManager, INetworkSwitchPoeManager { + /// + /// Event that is raised when the state of a switch port changes, such as a VLAN change or PoE state change. + /// + event EventHandler PortStateChanged; + + } + + /// + /// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes. + /// + public class NetworkSwitchPortEventArgs : EventArgs + { + /// + /// The identifier of the port that changed state (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco). + /// + public string Port { get; private set; } + + /// + /// The type of event that occurred on the port (e.g. VLAN change, PoE enabled/disabled). + /// + public NetworkSwitchPortEventType EventType { get; private set; } + + /// + /// Constructor for NetworkSwitchPortEventArgs + /// + /// The identifier of the port that changed state + /// The type of event that occurred on the port + public NetworkSwitchPortEventArgs(string port, NetworkSwitchPortEventType eventType) + { + Port = port; + EventType = eventType; + } + } + + /// + /// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes. + /// + public enum NetworkSwitchPortEventType + { + /// + /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan + /// + VlanChanged, + /// + /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState + /// + PoEDisabled, + + /// + /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState + /// + PoEEnabled } } From 6db7581295d746040b0a84ffa058f1f9d53f0ca9 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 20 Mar 2026 14:48:58 -0600 Subject: [PATCH 03/33] feat: add Unknown event type to NetworkSwitchPortEventType enum for improved event handling --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 6174ff282..0aaf89d29 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -84,6 +84,11 @@ public NetworkSwitchPortEventArgs(string port, NetworkSwitchPortEventType eventT /// public enum NetworkSwitchPortEventType { + /// + /// Indicates that the type of event is unknown or cannot be determined. + /// + Unknown, + /// /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan /// From 84c730b7a1255c8c167d784f186ec68be4997cfe Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 20 Mar 2026 14:53:21 -0600 Subject: [PATCH 04/33] feat: enhance NetworkSwitchPortEventType enum with additional states for VLAN and PoE changes --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 0aaf89d29..aca383330 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -89,16 +89,31 @@ public enum NetworkSwitchPortEventType /// Unknown, + /// + /// Indicates that a VLAN change is in progress on the port, either through a call to SetPortVlan or an external change detected by polling. + /// + VlanChangeInProgress, + /// /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan /// VlanChanged, + /// + /// Indicates that PoE is being disabled on the port, either through a call to SetPortPoeState or an external change detected by polling. + /// + PoeDisableInProgress, + /// /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState /// PoEDisabled, + /// + /// Indicates that PoE is being enabled on the port, either through a call to SetPortPoeState or an external change detected by polling. + /// + PoeEnableInProgress, + /// /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState /// From 1b10910cc28324b98feef7ed6f15400b1fdc6632 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Wed, 1 Apr 2026 08:56:10 -0600 Subject: [PATCH 05/33] fix: update GetFeedbacksForDeviceRequestHandler to return JSON response for missing device --- .../GetFeedbacksForDeviceRequestHandler.cs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs index ca9eeb812..7d947e19f 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs @@ -1,13 +1,14 @@ -using System.Linq; +using System; +using System.Linq; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core.Web.RequestHandlers; namespace PepperDash.Essentials.Core.Web.RequestHandlers { - /// - /// Represents a GetFeedbacksForDeviceRequestHandler - /// + /// + /// Represents a GetFeedbacksForDeviceRequestHandler + /// public class GetFeedbacksForDeviceRequestHandler : WebApiBaseRequestHandler { /// @@ -51,8 +52,20 @@ protected override void HandleGet(HttpCwsContext context) var device = DeviceManager.GetDeviceForKey(deviceObj.ToString()) as IHasFeedback; if (device == null) { - context.Response.StatusCode = 404; - context.Response.StatusDescription = "Not Found"; + 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(), + IntValues = Array.Empty(), + SerialValues = Array.Empty() + }; + var respJs = JsonConvert.SerializeObject(resp, Formatting.Indented); + + context.Response.Write(respJs, false); + context.Response.End(); return; @@ -76,7 +89,7 @@ from feedback in device.Feedbacks.OfType() Value = feedback.IntValue }; - var stringFeedback = + var stringFeedback = from feedback in device.Feedbacks.OfType() where !string.IsNullOrEmpty(feedback.Key) select new From 6587444970842af8aba769121979dbe11441e6b0 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 21 May 2026 13:11:39 -0600 Subject: [PATCH 06/33] fix: update routing logic and enhance logging in Extensions and MockVC classes --- .../Routing/Extensions.cs | 20 ++++++++++++------- .../VideoCodec/MockVC/MockVC.cs | 2 +- .../VideoCodec/VideoCodecBase.cs | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 631ebd994..be533b708 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -249,7 +249,7 @@ public static (RouteDescriptor, RouteDescriptor) GetRouteToSource(this IRoutingI } // otherwise, audioVideo needs to be handled as two steps. - Debug.LogDebug(destination, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", source.Key, signalType); + Debug.LogDebug(destination, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", destination.Key, source.Key, signalType); RouteDescriptor audioRouteDescriptor; @@ -374,24 +374,28 @@ public static void MapDestinationsToSources() IndexTieLines(); } - var sinks = DeviceManager.AllDevices.OfType().Where(d => !(d is IRoutingInputsOutputs)); - var sources = DeviceManager.AllDevices.OfType().Where(d => !(d is IRoutingInputsOutputs)); + var sinks = DeviceManager.AllDevices.OfType(); + var sources = DeviceManager.AllDevices.OfType(); - foreach (var sink in sinks) + foreach (var sink in sinks.Where(d => !(d is IRoutingInputsOutputs))) { - foreach (var source in sources) + foreach (var source in sources.Where(d => !(d is IRoutingInputsOutputs))) { foreach (var inputPort in sink.InputPorts) { foreach (var outputPort in source.OutputPorts) { - var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, inputPort.Type, inputPort, outputPort); + var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, outputPort.Type, inputPort, outputPort); if (audioOrSingleRoute == null && videoRoute == null) { continue; } + Debug.LogVerbose("AudioOrSingleRoute Found: {audioRoute}", audioOrSingleRoute); + + Debug.LogVerbose("VideoRoute Found: {videoRoute}", videoRoute); + if (audioOrSingleRoute != null) { // Only add routes that have actual switching steps @@ -646,6 +650,8 @@ private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOu // Only the ones that are routing devices var midpointTieLines = destinationTieLines.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs); + Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count(), destination.Key); + //Create a list for tracking already checked devices to avoid loops, if it doesn't already exist from previous iteration if (alreadyCheckedDevices == null) alreadyCheckedDevices = new List(); @@ -685,7 +691,7 @@ private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOu if (goodInputPort == null) { - Debug.LogVerbose(destination, "No route found to {0}", source.Key); + Debug.LogVerbose(destination, "No route found to {0} from destination {1} for type {2}", source.Key, destination.Key, signalType); // Cache this as an impossible route _impossibleRoutes.TryAdd(routeKey, 0); diff --git a/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs b/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs index e9f7908ce..bdefa09dc 100644 --- a/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs +++ b/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs @@ -21,7 +21,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec /// /// Represents a MockVC /// - public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets + public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets, IRoutingInputs { /// /// Gets or sets the PropertiesConfig diff --git a/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs b/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs index b0b5ef55b..04c7e64d9 100644 --- a/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs +++ b/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs @@ -26,7 +26,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec /// /// Base class for video codec devices /// - public abstract class VideoCodecBase : ReconfigurableDevice, IRoutingInputsOutputs, + public abstract class VideoCodecBase : ReconfigurableDevice, IUsageTracking, IHasDialer, IHasContentSharing, ICodecAudio, iVideoCodecInfo, IBridgeAdvanced, IHasStandbyMode { private const int XSigEncoding = 28591; From 92d13f29a9dfac9e9a919f152fc6ce6cd3b47584 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 21 May 2026 16:43:00 -0600 Subject: [PATCH 07/33] fix: refine routing logic by filtering IRoutingInputs and IRoutingOutputs, enhance logging for route mapping --- .../Routing/Extensions.cs | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index be533b708..78d0732ee 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -374,12 +374,14 @@ public static void MapDestinationsToSources() IndexTieLines(); } - var sinks = DeviceManager.AllDevices.OfType(); - var sources = DeviceManager.AllDevices.OfType(); + var sinks = DeviceManager.AllDevices.OfType() + .Where(d => !(d is IRoutingInputsOutputs)).ToList(); + var sources = DeviceManager.AllDevices.OfType() + .Where(d => !(d is IRoutingInputsOutputs)).ToList(); - foreach (var sink in sinks.Where(d => !(d is IRoutingInputsOutputs))) + foreach (var sink in sinks) { - foreach (var source in sources.Where(d => !(d is IRoutingInputsOutputs))) + foreach (var source in sources) { foreach (var inputPort in sink.InputPorts) { @@ -392,10 +394,6 @@ public static void MapDestinationsToSources() continue; } - Debug.LogVerbose("AudioOrSingleRoute Found: {audioRoute}", audioOrSingleRoute); - - Debug.LogVerbose("VideoRoute Found: {videoRoute}", videoRoute); - if (audioOrSingleRoute != null) { // Only add routes that have actual switching steps @@ -404,6 +402,10 @@ public static void MapDestinationsToSources() continue; } + Debug.LogVerbose("Route mapped: {source} -> {sink} via {input}/{output}, type {type}", + source.Key, sink.Key, + inputPort.Key, outputPort.Key, audioOrSingleRoute.SignalType); + // Add to the appropriate collection(s) based on signal type // Note: A single route descriptor with combined flags (e.g., AudioVideo) will be added once per matching signal type if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Audio)) @@ -435,6 +437,10 @@ public static void MapDestinationsToSources() continue; } + Debug.LogVerbose("Video route mapped: {source} -> {sink} via {input}/{output}", + source.Key, sink.Key, + inputPort.Key, outputPort.Key); + RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(videoRoute); } } @@ -648,10 +654,12 @@ private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOu // No direct tie? Run back out on the inputs' attached devices... // Only the ones that are routing devices - var midpointTieLines = destinationTieLines.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs); - - Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count(), destination.Key); + var midpointTieLines = destinationTieLines + .Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs) + .ToList(); + Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count, destination.Key); + //Create a list for tracking already checked devices to avoid loops, if it doesn't already exist from previous iteration if (alreadyCheckedDevices == null) alreadyCheckedDevices = new List(); From 74a12c9671e4dac87b4eecef6ce8abb7e12f5185 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 21 May 2026 18:47:25 -0600 Subject: [PATCH 08/33] fix: add null or empty checks for midpoint keys in RoutingFeedbackManager --- .../Routing/RoutingFeedbackManager.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs index 16822ff2f..7fe41c5c7 100644 --- a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs +++ b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs @@ -66,6 +66,9 @@ private void BuildMidpointSinkMap() foreach (var midpointKey in upstreamMidpoints) { + if (string.IsNullOrEmpty(midpointKey)) + continue; + if (!midpointToSinksMap.ContainsKey(midpointKey)) midpointToSinksMap[midpointKey] = new HashSet(); @@ -115,7 +118,8 @@ private void TraceUpstreamMidpoints(TieLine tieLine, HashSet midpoints, if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint) { - midpoints.Add(midpoint.Key); + if (!string.IsNullOrEmpty(midpoint.Key)) + midpoints.Add(midpoint.Key); // Find upstream TieLines connected to this midpoint's inputs var midpointInputs = (midpoint as IRoutingInputs)?.InputPorts; @@ -244,6 +248,9 @@ private void RebuildMapForSink(IRoutingSinkWithSwitchingWithInputPort sink) var upstreamMidpoints = GetUpstreamMidpoints(sink); foreach (var midpointKey in upstreamMidpoints) { + if (string.IsNullOrEmpty(midpointKey)) + continue; + if (!midpointToSinksMap.ContainsKey(midpointKey)) midpointToSinksMap[midpointKey] = new HashSet(); From b32bab0d335b75a56de154c4352086e1f20db7c3 Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Tue, 9 Jun 2026 09:32:59 -0500 Subject: [PATCH 09/33] fix: remove impossibleRoutes cache The cache wasn't being cleared correctly, and was an unnecessary add. --- .../Routing/Extensions.cs | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 78d0732ee..5063f8b30 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -52,13 +51,6 @@ public static class Extensions /// private static Dictionary> _tieLinesBySource; - /// - /// Cache of failed route attempts to avoid re-checking impossible paths. - /// Format: "sourceKey|destKey|signalType" - /// Uses ConcurrentDictionary as a thread-safe set (byte value is unused). - /// - private static readonly ConcurrentDictionary _impossibleRoutes = new ConcurrentDictionary(); - /// /// Indexes all TieLines by source and destination device keys for faster lookups. /// Should be called once at system startup after all TieLines are created. @@ -121,29 +113,6 @@ private static IEnumerable GetTieLinesForSource(string sourceKey) return TieLineCollection.Default.Where(t => t.SourcePort.ParentDevice.Key == sourceKey); } - /// - /// Creates a cache key for route impossibility tracking. - /// - /// Source device key - /// Destination device key - /// Source port key - /// Destination port key - /// Signal type - /// Cache key string - private static string GetRouteKey(string sourceKey, string destKey, string sourcePortKey, string destinationPortKey, eRoutingSignalType type) - { - return $"{sourceKey}|{destKey}|{sourcePortKey}|{destinationPortKey}|{type}"; - } - - /// - /// Clears the impossible routes cache. Should be called if TieLines are added/removed at runtime. - /// - public static void ClearImpossibleRoutesCache() - { - _impossibleRoutes.Clear(); - Debug.LogInformation("Impossible routes cache cleared"); - } - /// /// Gets any existing RouteDescriptor for a destination, clears it using ReleaseRoute /// and then attempts a new Route and if sucessful, stores that RouteDescriptor @@ -594,14 +563,6 @@ private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOu { cycle++; - // Check if this route has already been determined to be impossible - var routeKey = GetRouteKey(source.Key, destination.Key, sourcePort?.Key ?? "auto", destinationPort?.Key ?? "auto", signalType); - if (_impossibleRoutes.ContainsKey(routeKey)) - { - Debug.LogVerbose("Route {0} is cached as impossible, skipping", routeKey); - return false; - } - Debug.LogVerbose("GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString()); RoutingInputPort goodInputPort = null; @@ -701,9 +662,6 @@ private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOu { Debug.LogVerbose(destination, "No route found to {0} from destination {1} for type {2}", source.Key, destination.Key, signalType); - // Cache this as an impossible route - _impossibleRoutes.TryAdd(routeKey, 0); - return false; } From 162a06f9e90aedcb6bdda10a38b81b48ccb3009c Mon Sep 17 00:00:00 2001 From: aknous Date: Wed, 10 Jun 2026 21:57:55 -0400 Subject: [PATCH 10/33] feat: adds interface for wireless sharing --- .../IHasWirelessSharing.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs new file mode 100644 index 000000000..6cd3835e6 --- /dev/null +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs @@ -0,0 +1,48 @@ +using System; + +namespace PepperDash.Essentials.Core.DeviceTypeInterfaces +{ + /// + /// Defines the contract for a wireless presentation endpoint that reports whether a wireless + /// sharing session is currently active. Implemented by platforms such as Crestron AirMedia, + /// Mersive Solstice, Barco ClickShare, Miracast/Teams receivers, etc. Allows consumers (e.g. + /// room plugins) to react to wireless sharing activity without taking a dependency on any + /// concrete device implementation. + /// + /// + /// This refers specifically to wireless screen/device mirroring, as distinct from in-call + /// content sharing on a video conference. + /// + public interface IHasWirelessSharing + { + /// + /// Reports whether a wireless sharing session is currently active (content is being presented). + /// + BoolFeedback IsSharingFeedback { get; } + + /// + /// Raised when wireless sharing starts or stops. The event args carry the new sharing state. + /// + event EventHandler SharingChanged; + } + + /// + /// Event arguments describing a change in wireless sharing state. + /// + public class WirelessSharingEventArgs : EventArgs + { + /// + /// True if a wireless sharing session is active (content is being presented), false otherwise. + /// + public bool IsSharing { get; private set; } + + /// + /// Creates a new . + /// + /// True if a wireless sharing session is active, false otherwise. + public WirelessSharingEventArgs(bool isSharing) + { + IsSharing = isSharing; + } + } +} From d18fca8a9889a99d5d59417c4e33d48b4b559865 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Wed, 10 Jun 2026 13:34:05 -0600 Subject: [PATCH 11/33] fix: handle null properties in DeviceMessageBase and DeviceStateMessageBase --- .../Messengers/DeviceMessageBase.cs | 8 ++++---- .../Messengers/DeviceStateMessageBase.cs | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs index 54a6ec361..0198df2f5 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs @@ -10,7 +10,7 @@ public abstract class DeviceMessageBase /// /// The device key /// - [JsonProperty("key")] + [JsonProperty("key", NullValueHandling = NullValueHandling.Ignore)] /// /// Gets or sets the Key /// @@ -19,19 +19,19 @@ public abstract class DeviceMessageBase /// /// The device name /// - [JsonProperty("name")] + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } /// /// The type of the message class /// - [JsonProperty("messageType")] + [JsonProperty("messageType", NullValueHandling = NullValueHandling.Ignore)] public string MessageType => GetType().Name; /// /// Gets or sets the MessageBasePath /// - [JsonProperty("messageBasePath")] + [JsonProperty("messageBasePath", NullValueHandling = NullValueHandling.Ignore)] public string MessageBasePath { get; set; } } diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs index a5df51a84..4241b69cd 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs @@ -12,7 +12,8 @@ public class DeviceStateMessageBase : DeviceMessageBase /// /// The interfaces implmented by the device sending the messsage /// - [JsonProperty("interfaces")] + [JsonProperty("interfaces", NullValueHandling = NullValueHandling.Ignore)] + [Obsolete("Interfaces is no longer supported and will be removed in a future release. Interfaces for all devices are now retrieved via the /joinroom endpoint in the MobileControlWebsocketServer")] public List Interfaces { get; private set; } /// From 6f2231a8d414c6f7a1a412afb5bb56c25dcceac8 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 11:28:39 -0600 Subject: [PATCH 12/33] 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 56c983ae1..1eeb27809 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -132,17 +132,24 @@ protected override void HandlePost(HttpCwsContext context) 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 13/33] 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 1eeb27809..e6044144f 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -48,6 +48,7 @@ protected override void HandleGet(Crestron.SimplSharp.WebScripting.HttpCwsContex CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); var port = 0; + string csIp = null; if (!Debug.WebsocketSink.IsRunning) { @@ -63,7 +64,7 @@ protected override void HandleGet(Crestron.SimplSharp.WebScripting.HttpCwsContex { 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 @@ protected override void HandleGet(Crestron.SimplSharp.WebScripting.HttpCwsContex 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 3031f4baa..eb3afec3b 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs @@ -264,6 +264,9 @@ protected void PostStatusMessage(DeviceStateMessageBase message, string clientId 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 14/33] 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 e6044144f..d1d27194f 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -58,15 +58,18 @@ protected override void HandleGet(Crestron.SimplSharp.WebScripting.HttpCwsContex // Start the WS Server Debug.WebsocketSink.StartServerAndSetPort(port); Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose); + } - // Attempt to forward the port to the CS LAN - try - { - var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType( - EthernetAdapterType.EthernetCSAdapter); - csIp = CrestronEthernetHelper.GetEthernetParameter( - CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); + // 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 result = CrestronEthernetHelper.AddPortForwarding( (ushort)port, (ushort)port, csIp, CrestronEthernetHelper.ePortMapTransport.TCP); @@ -80,25 +83,26 @@ protected override void HandleGet(Crestron.SimplSharp.WebScripting.HttpCwsContex 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 15/33] 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 eeba5772e..cfbf57852 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -72,6 +72,20 @@ public string Url /// 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 @@ public void StartServerAndSetPort(int port) { 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 d1d27194f..59c662dc2 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 @@ protected override void HandleGet(Crestron.SimplSharp.WebScripting.HttpCwsContex else { Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port); + StartPortForwardTimeout(port, csIp); } } } @@ -126,6 +130,8 @@ protected override void HandleGet(Crestron.SimplSharp.WebScripting.HttpCwsContex /// protected override void HandlePost(HttpCwsContext context) { + CancelPortForwardTimeout(); + var port = Debug.WebsocketSink.Port; Debug.WebsocketSink.StopServer(); @@ -174,5 +180,55 @@ protected override void HandlePost(HttpCwsContext context) 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 16/33] 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 17/33] 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 887f17892..e6365571e 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 ec7219a37..9beed963d 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 @@ public class MobileControlConfig /// 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 18/33] 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 e6365571e..8603d5b67 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs @@ -4,7 +4,7 @@ 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 19/33] 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 9beed963d..27c9d31e1 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs @@ -41,7 +41,7 @@ public class MobileControlConfig 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 20/33] 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 27c9d31e1..963e7fd58 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs @@ -41,7 +41,7 @@ public class MobileControlConfig 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 @@ public enum MCIconSet /// 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 21/33] 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 22/33] 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 382f5d558..ac4e80d84 100644 --- a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs +++ b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs @@ -3,6 +3,7 @@ using System.Linq; using Crestron.SimplSharp; using PepperDash.Core; +using PepperDash.Core.Logging; using Serilog.Events; namespace PepperDash.Essentials.Core @@ -296,19 +297,14 @@ private static string UpdateSecret(ISecretProvider provider, string key, string { 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 @@ private static string SetSecret(ISecretProvider provider, string key, string sec { 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 @@ private static void DeleteSecretProcess(string cmd) 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 23/33] 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 8e9dbb6f44faf570102cd1c7899219b2999a90cd Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 7 Jul 2026 10:54:59 -0500 Subject: [PATCH 24/33] 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 b4990974484d823036d6c5bfe021cdc1164715e2 Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 7 Jul 2026 11:09:59 -0500 Subject: [PATCH 25/33] 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 707195b8c5544b449846ae7da2475da5b3997c03 Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 14 Jul 2026 19:48:32 -0500 Subject: [PATCH 26/33] 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 ae01c59b975e4a751cf251433a60b61c978fdbea Mon Sep 17 00:00:00 2001 From: equinoy Date: Wed, 15 Jul 2026 09:52:07 -0500 Subject: [PATCH 27/33] 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 a638ffa252a0e28ff26dde5b7c0d223bef345158 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 28/33] 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 1ffa010905d82461f52213dcdc73e05d0fab9afc 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 29/33] 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 40693f84306e9713bde76c516998da05f7cabed1 Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:36:58 -0500 Subject: [PATCH 30/33] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../GetFeedbacksForDeviceRequestHandler.cs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs index 7d947e19f..9831fa911 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs @@ -52,20 +52,8 @@ protected override void HandleGet(HttpCwsContext context) 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(), - IntValues = Array.Empty(), - SerialValues = Array.Empty() - }; - var respJs = JsonConvert.SerializeObject(resp, Formatting.Indented); - - context.Response.Write(respJs, false); - + context.Response.StatusCode = 404; + context.Response.StatusDescription = "Not Found"; context.Response.End(); return; From 2d453ba8b5206cbd04aa687f0b30a33d009a7106 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 31/33] 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 c77181714baf2c27b2aab5adbb4ef764f496d10d 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 32/33] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- 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}"; From a4f6c10e0cbcfd8eb3670c8f5047892c9006c46d 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 33/33] 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}";