From c3511cd1a6c8e50fa6df2d49cd0d4dec2a03299d Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Tue, 9 Jun 2026 09:32:59 -0500 Subject: [PATCH 01/66] 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 be533b70..9b9c458e 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 @@ namespace PepperDash.Essentials.Core /// 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 @@ namespace PepperDash.Essentials.Core 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 @@ -588,14 +557,6 @@ namespace PepperDash.Essentials.Core { 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; @@ -693,9 +654,6 @@ namespace PepperDash.Essentials.Core { 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 5f26cb98fd4def73cafba659d908f60c4bc729cb Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 11:28:39 -0600 Subject: [PATCH 02/66] 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 907eb2f3972831c66e0250d87d1761b8901225a6 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 14:29:49 -0600 Subject: [PATCH 03/66] 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 782bb6c057c0b9c901313a47f50313e5c023c2eb Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 14:52:35 -0600 Subject: [PATCH 04/66] 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 39744553375044e0daee30168c5cc75d3d502214 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 15:08:34 -0600 Subject: [PATCH 05/66] 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 a7b839296e5de20293c842ea08d52a36674315db Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:05:14 -0500 Subject: [PATCH 06/66] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index aca38333..077e2038 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -115,7 +115,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces PoeEnableInProgress, /// - /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState + /// Indicates that PoE has been enabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling. /// PoEEnabled } From e03e45ccf5c0fd3c4510766fb14cf5691808c929 Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:05:25 -0500 Subject: [PATCH 07/66] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 077e2038..5e1e4f1a 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -80,7 +80,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces } /// - /// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes. + /// Enumeration of network switch port state change event types (e.g. VLAN changes or PoE state changes). /// public enum NetworkSwitchPortEventType { From b982219e2f0ef1bfab98a42fa6f934acfd9c247a Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:05:36 -0500 Subject: [PATCH 08/66] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 5e1e4f1a..37fb024d 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -95,7 +95,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces VlanChangeInProgress, /// - /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan + /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan or an external change detected by polling. /// VlanChanged, From a22047410158b065a42eaf858b9345ade92179ee Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:05:50 -0500 Subject: [PATCH 09/66] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 37fb024d..6c6e5598 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -105,7 +105,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces PoeDisableInProgress, /// - /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState + /// Indicates that PoE has been disabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling. /// PoEDisabled, From 8d3edde28ca6a50e7dc0551af2c41a5a72c9d11b Mon Sep 17 00:00:00 2001 From: aknous Date: Wed, 17 Jun 2026 10:06:00 -0400 Subject: [PATCH 10/66] feat: adds config props to disable power on/off automation --- .../Displays/ScreenLiftController.cs | 18 ++++++++++++++++++ .../ScreenLiftControllerConfigProperties.cs | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs index 80c1f8ee..d542f52a 100644 --- a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs +++ b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs @@ -152,6 +152,15 @@ namespace PepperDash.Essentials.Devices.Common.Shades private void IsCoolingDownFeedback_OutputChange(object sender, FeedbackEventArgs e) { + if (Config.DisableAutoRaiseOnPowerOff) + { + this.LogDebug( + "Auto-raise on power-off disabled for {type}; leaving position unchanged (manual control only)", + Type + ); + return; + } + if ( !DisplayDevice.IsCoolingDownFeedback.BoolValue && Type == eScreenLiftControlType.lift @@ -174,6 +183,15 @@ namespace PepperDash.Essentials.Devices.Common.Shades { if (DisplayDevice.IsWarmingUpFeedback.BoolValue) { + if (Config.DisableAutoLowerOnPowerOn) + { + this.LogDebug( + "Auto-lower on power-on disabled for {type}; leaving position unchanged (manual control only)", + Type + ); + return; + } + Lower(); } } diff --git a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs index 1c4f9906..88113a08 100644 --- a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs +++ b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs @@ -41,5 +41,23 @@ namespace PepperDash.Essentials.Devices.Common.Shades /// [JsonProperty("muteOnScreenUp")] public bool MuteOnScreenUp { get; set; } + + /// + /// When true, this controller does NOT automatically lower when its assigned display powers on + /// (warms up). Manual Raise/Lower still work, and the power-off auto-raise is unaffected. Intended + /// for a projector screen that must not auto-drop in a public space for safety, while the projector + /// lift (a separate controller) can still drop automatically. + /// + [JsonProperty("disableAutoLowerOnPowerOn")] + public bool DisableAutoLowerOnPowerOn { get; set; } + + /// + /// When true, this controller does NOT automatically raise when its assigned display powers off + /// (cools down). Manual Raise/Lower still work, and the power-on auto-lower is unaffected. The + /// companion to ; together they make a controller fully + /// manual while leaving other controllers (e.g. the lift) on their default automatic behavior. + /// + [JsonProperty("disableAutoRaiseOnPowerOff")] + public bool DisableAutoRaiseOnPowerOff { get; set; } } } From 3286d27898ef0378e27d4e7cef862bb0544489eb 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 11/66] Initial plan From af5611e403dc7fa8651b595bc493550db7be6361 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 12/66] 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 2fac0ca926b752d96b8bb777e923a5830ffc5baa Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 26 Jun 2026 14:32:22 -0600 Subject: [PATCH 13/66] 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 640bd7a8a781a33f734a5eb03086a79550ac344a 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 14/66] 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 0240887d93e890a56c42ce8c8e78f794d5406215 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 26 Jun 2026 15:57:52 -0600 Subject: [PATCH 15/66] 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 8ac4eb75843f1267f410443e15c1a267792213a2 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 16/66] refactor: marked mobile control subscription items as obsolete Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com> From 68c44e46aef807c2544f711d95baa0a519e4264c Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Thu, 2 Jul 2026 15:06:51 -0500 Subject: [PATCH 17/66] 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 64d60dacc256bea48f8642c0f204486f2bd681f4 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Thu, 2 Jul 2026 17:13:26 -0500 Subject: [PATCH 18/66] feat: update version object Add touchpanelWrapperApp, userInterfaces, and repoUrl to VersionData config schema VersionData/NugetVersion previously only modeled the essentials and packages entries under the versions node. This adds support for the touchpanelWrapperApp (single object) and userInterfaces (array) nodes, plus a repoUrl property on NugetVersion so it round-trips instead of being silently dropped, matching the schema produced by the vsce-essentials-version-manager extension. --- src/Directory.Build.props | 4 ++-- .../Config/Essentials/EssentialsConfig.cs | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7435df6f..73c3a7bb 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,11 +1,11 @@ - 2.29.0-local + 2.36.6-local $(Version) PepperDash Technology PepperDash Technology PepperDash Essentials - Copyright © 2025 + Copyright © 2026 https://github.com/PepperDash/Essentials git Crestron; 4series diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs index 6ffe07d2..c25ab40f 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs @@ -134,12 +134,25 @@ namespace PepperDash.Essentials.Core.Config [JsonProperty("packages")] public List Packages { get; set; } + /// + /// Gets or sets the touchpanel wrapper app version + /// + [JsonProperty("touchpanelWrapperApp")] + public NugetVersion TouchpanelWrapperApp { get; set; } + + /// + /// Gets or sets the list of user interface packages + /// + [JsonProperty("userInterfaces")] + public List UserInterfaces { get; set; } + /// /// Initializes a new instance of the class. /// public VersionData() { Packages = new List(); + UserInterfaces = new List(); } } @@ -159,6 +172,12 @@ namespace PepperDash.Essentials.Core.Config /// [JsonProperty("packageId")] public string PackageId { get; set; } + + /// + /// Gets or sets the RepoUrl + /// + [JsonProperty("repoUrl")] + public string RepoUrl { get; set; } } /// From 588057f3bacc606bdd1295f6535bf731a4ecc2dd Mon Sep 17 00:00:00 2001 From: jkdevito Date: Sun, 5 Jul 2026 20:57:05 -0500 Subject: [PATCH 19/66] feat: add packageManifest CWS API route New GET https://{ip}/cws/app{xx}/api/packageManifest route returning a JSON package manifest (Essentials + plugins + user interfaces) shaped to hydrate the vsce-essentials-version-manager extension's VersionsSnapshot object. - EssentialsConfig.cs: add [JsonProperty("versions")] to EssentialsConfig.Versions (was attribute-less); add NugetVersion.Name; NullValueHandling.Ignore on PackageId/RepoUrl/Name. - New GetPackageManifestRequestHandler: deep-copies the config's VersionData (never mutates the live config object), then enriches it via reflection: - essentials: version from Global.AssemblyVersion, repoUrl/name from the RepositoryUrl AssemblyMetadata + AssemblyProduct of PepperDash.Essentials.Core's own assembly (PluginLoader.EssentialsAssembly.Assembly is null at runtime due to a pre-existing name-matching bug, so this route reads its own loaded assembly instead), packageId from config or a constant. - packages[]: merges PluginLoader.EssentialsPluginAssemblies (matched to config packages by packageId via AssemblyTitle -> AssemblyName -> AssemblyName minus a trailing .4Series suffix) with reflection supplying version and filling missing repoUrl/name; unmatched loaded assemblies are emitted without a packageId; configured-but-not-loaded packages pass through unchanged. - userInterfaces/touchpanelWrapperApp are passed through from config as-is. - Entries with no resolvable version are skipped (the extension's parser drops entries whose version isn't a string). - EssentialsWebApi.cs: register the new packageManifest route next to versions. Build verified clean (Core + Essentials program, 0 errors). Not yet tested on hardware. --- .../Config/Essentials/EssentialsConfig.cs | 11 +- .../Web/EssentialsWebApi.cs | 5 + .../GetPackageManifestRequestHandler.cs | 231 ++++++++++++++++++ 3 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs index c25ab40f..44275ef0 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs @@ -105,6 +105,7 @@ namespace PepperDash.Essentials.Core.Config /// /// Gets or sets the Versions /// + [JsonProperty("versions")] public VersionData Versions { get; set; } /// @@ -170,14 +171,20 @@ namespace PepperDash.Essentials.Core.Config /// /// Gets or sets the PackageId /// - [JsonProperty("packageId")] + [JsonProperty("packageId", NullValueHandling = NullValueHandling.Ignore)] public string PackageId { get; set; } /// /// Gets or sets the RepoUrl /// - [JsonProperty("repoUrl")] + [JsonProperty("repoUrl", NullValueHandling = NullValueHandling.Ignore)] public string RepoUrl { get; set; } + + /// + /// Gets or sets the human-readable name + /// + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get; set; } } /// diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index 3cdb8433..cfbaa1df 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -95,6 +95,11 @@ namespace PepperDash.Essentials.Core.Web Name = "ReportVersions", RouteHandler = new ReportVersionsRequestHandler() }, + new HttpCwsRoute("packageManifest") + { + Name = "GetPackageManifest", + RouteHandler = new GetPackageManifestRequestHandler() + }, new HttpCwsRoute("appdebug") { Name = "AppDebug", diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs new file mode 100644 index 00000000..9be5e78f --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -0,0 +1,231 @@ +using System; +using System.Linq; +using System.Reflection; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Config; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Represents a GetPackageManifestRequestHandler + /// + public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler + { + /// + /// Constructor + /// + /// + /// base(true) enables CORS support by default + /// + public GetPackageManifestRequestHandler() + : base(true) + { + } + + /// + /// Handles GET method requests + /// + /// + protected override void HandleGet(HttpCwsContext context) + { + try + { + var result = CloneVersionData(ConfigReader.ConfigObject?.Versions) ?? new VersionData(); + + PopulateEssentials(result); + PopulatePackages(result); + + var js = JsonConvert.SerializeObject(result, Formatting.Indented); + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(js, false); + context.Response.End(); + } + catch (Exception) + { + context.Response.StatusCode = 500; + context.Response.StatusDescription = "Internal Server Error"; + context.Response.End(); + } + } + + /// + /// Deep-copies the config's VersionData so the live config object is never mutated + /// + private static VersionData CloneVersionData(VersionData source) + { + if (source == null) + { + return null; + } + + var json = JsonConvert.SerializeObject(source); + return JsonConvert.DeserializeObject(json); + } + + /// + /// Enriches (or creates) the essentials entry from the loaded PepperDash.Essentials.Core assembly + /// + private static void PopulateEssentials(VersionData result) + { + var essentials = result.Essentials ?? new NugetVersion(); + + essentials.Version = Global.AssemblyVersion; + + // PepperDash_Essentials_Core.dll - same repo/Directory.Build.props as PepperDashEssentials.dll, + // and unlike PluginLoader.EssentialsAssembly, this Assembly reference is never null at runtime. + var essentialsAssembly = typeof(GetPackageManifestRequestHandler).Assembly; + + var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl")); + if (!string.IsNullOrEmpty(repoUrl)) + { + essentials.RepoUrl = repoUrl; + } + + var name = GetAssemblyProduct(essentialsAssembly); + if (!string.IsNullOrEmpty(name)) + { + essentials.Name = name; + } + + if (string.IsNullOrEmpty(essentials.PackageId)) + { + essentials.PackageId = "PepperDash.Essentials"; + } + + result.Essentials = essentials; + } + + /// + /// Merges reflection data from loaded plugin assemblies with the config's packages list + /// + private static void PopulatePackages(VersionData result) + { + var configPackages = result.Packages ?? new System.Collections.Generic.List(); + var matchedConfigPackages = new System.Collections.Generic.HashSet(); + var mergedPackages = new System.Collections.Generic.List(); + + foreach (var loaded in PluginLoader.EssentialsPluginAssemblies.Where(a => a.Assembly != null)) + { + var reflectedVersion = loaded.Version; + if (string.IsNullOrEmpty(reflectedVersion)) + { + // Never emit an entry with no version - the extension's parser drops entries + // whose version isn't a string. + continue; + } + + var reflectedRepoUrl = TrimTrailingGit(GetAssemblyMetadataValue(loaded.Assembly, "RepositoryUrl")); + var reflectedName = GetAssemblyProduct(loaded.Assembly); + + var assemblyTitle = GetAssemblyTitle(loaded.Assembly); + var assemblyName = loaded.Assembly.GetName().Name; + var assemblyNameNoSeriesSuffix = StripTrailingSeriesSuffix(assemblyName); + + var match = configPackages.FirstOrDefault(p => + !matchedConfigPackages.Contains(p) && + !string.IsNullOrEmpty(p.PackageId) && + (string.Equals(p.PackageId, assemblyTitle, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyName, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyNameNoSeriesSuffix, StringComparison.OrdinalIgnoreCase))); + + if (match != null) + { + matchedConfigPackages.Add(match); + + mergedPackages.Add(new NugetVersion + { + PackageId = match.PackageId, + Version = reflectedVersion, + RepoUrl = !string.IsNullOrEmpty(match.RepoUrl) ? match.RepoUrl : reflectedRepoUrl, + Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName + }); + } + else + { + // Loaded but not present (or not matched) in config - emit without a packageId + mergedPackages.Add(new NugetVersion + { + Version = reflectedVersion, + RepoUrl = reflectedRepoUrl, + Name = reflectedName + }); + } + } + + // Configured but not currently loaded - pass through unchanged + mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p))); + + result.Packages = mergedPackages; + } + + private static string GetAssemblyMetadataValue(Assembly assembly, string key) + { + if (assembly == null) + { + return null; + } + + var match = assembly.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false) + .Cast() + .FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase)); + + return match?.Value; + } + + private static string GetAssemblyProduct(Assembly assembly) + { + if (assembly == null) + { + return null; + } + + var attribute = assembly.GetCustomAttributes(typeof(AssemblyProductAttribute), false) + .FirstOrDefault() as AssemblyProductAttribute; + + return attribute?.Product; + } + + private static string GetAssemblyTitle(Assembly assembly) + { + if (assembly == null) + { + return null; + } + + var attribute = assembly.GetCustomAttributes(typeof(AssemblyTitleAttribute), false) + .FirstOrDefault() as AssemblyTitleAttribute; + + return attribute?.Title; + } + + private static string StripTrailingSeriesSuffix(string assemblyName) + { + const string suffix = ".4Series"; + + if (string.IsNullOrEmpty(assemblyName) || !assemblyName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return assemblyName; + } + + return assemblyName.Substring(0, assemblyName.Length - suffix.Length); + } + + private static string TrimTrailingGit(string repoUrl) + { + const string suffix = ".git"; + + if (string.IsNullOrEmpty(repoUrl) || !repoUrl.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return repoUrl; + } + + return repoUrl.Substring(0, repoUrl.Length - suffix.Length); + } + } +} From 6f2231a8d414c6f7a1a412afb5bb56c25dcceac8 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 11:28:39 -0600 Subject: [PATCH 20/66] 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 21/66] 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 22/66] 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 23/66] 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 24/66] 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 25/66] 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 26/66] 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 27/66] 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 28/66] 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 29/66] 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 30/66] 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 31/66] 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 32/66] 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 33/66] 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 2f7789b374bf91112665f3f97e1a4b07bcd1b5f0 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 16:22:23 -0500 Subject: [PATCH 34/66] fix: prefer embedded PackageId assembly metadata in packageManifest API WHAT / WHY ---------- GetPackageManifestRequestHandler previously identified a loaded plugin's NuGet PackageId by fuzzy-matching AssemblyTitle/AssemblyName against the config's packageId - a chain that silently fails for any plugin whose AssemblyTitle/AssemblyName doesn't happen to match its PackageId (verified against real shipped plugin DLLs; see FINDINGS-nuget-packageid-gaps.md). epi-symetrix-dsp and epi-shure-mxa have already backported a Directory.Build.props change that embeds ``, giving Essentials an unambiguous, authoritative PackageId via AssemblyMetadataAttribute("PackageId", ...) instead of guessing. Validated by building both plugins and inspecting the generated AssemblyInfo.cs. Changes: - src/Directory.Build.props: add the same AssemblyMetadata PackageId item, so every Essentials-owned assembly (Core, Essentials, Devices.Common, MobileControl, MobileControl.Messengers) now embeds its real PackageId too - previously none of them did. - GetPackageManifestRequestHandler.cs: - PopulatePackages: read AssemblyMetadataAttribute("PackageId", ...) from loaded plugin assemblies and use it as the first-priority match/identity signal, ahead of the AssemblyTitle -> AssemblyName -> AssemblyName-minus- ".4Series" fallback chain. Loaded-but-unconfigured plugins that carry this metadata now report a PackageId in the manifest instead of null. - PopulateEssentials: replace the hardcoded "PepperDash.Essentials" fallback (which matched none of the real PackageIds) with the reflected value from PepperDash.Essentials.Core's own assembly metadata. Fully backward compatible: plugins without the updated Directory.Build.props (most existing epi-* repos today) fall through to the prior fallback chain unchanged. RECOMMENDATIONS - Essentials & sub-projects (this repo) -------------------------------------------------------- - AssemblyName/AssemblyTitle drift from PackageId across sub-projects (confirmed via generated AssemblyInfo.cs, not assumed): PepperDash.Essentials.Core: PackageId "PepperDash.Essentials.Core" vs AssemblyName "PepperDash_Essentials_Core" PepperDash.Essentials.Devices.Common: PackageId "PepperDash.Essentials.Devices.Common" vs AssemblyName "Essentials Devices Common" PepperDash.Essentials.MobileControl: PackageId "PepperDash.Essentials.MobileControl" vs AssemblyName "epi-essentials-mobile-control" PepperDash.Essentials.MobileControl.Messengers: PackageId "...Messengers" vs AssemblyName "mobile-control-messengers" Only PepperDash.Essentials and PepperDash.Core happen to agree. Fixing AssemblyName changes the physical .dll filename for existing consumers, so this needs a deliberate, versioned decision - not bundled here. - Once this ships and bakes for a release or two, consider deleting the now-redundant "PepperDash.Essentials" hardcoded string entirely and the Product/AssemblyTitle-based Name fallback, since AssemblyMetadata PackageId supersedes both for any assembly built after this change. RECOMMENDATIONS - EPI plugin repos (epi-*) ------------------------------------------- - Backport `` into every existing epi-* repo's src/Directory.Build.props (recommendation E from FINDINGS-nuget-packageid-gaps.md). This is opt-in and additive - repos that skip it keep working via the existing fallback chain, but gain nothing until they backport it and cut a new release. - Land the corresponding fix in EssentialsPluginTemplate (src/Directory.Build.props + src/epi-make-model.4Series.csproj) so all *new* plugin repos get this by default, and fix the template's own AssemblyTitle/PackageId drift ("Plugin" vs "Plugins") while there. - Already-published plugin versions can't be retroactively fixed - this only takes effect on a plugin's next release after adopting the template change. WORKFLOW RECOMMENDATIONS ------------------------- - Extend workflow-templates' essentialsplugins-4Series-builds.yml "Check Package Name" step to validate the built DLL's embedded AssemblyMetadataAttribute("PackageId", ...) (and/or AssemblyTitle as a fallback) against the repo-derived expected package name - today it only compares the .nupkg filename, which would not have caught drift like epi-display-samsung-mdc's AssemblyTitle mismatch. - Sequence this after the template + per-repo backports have landed and baked for a release cycle, otherwise it will fail CI for every epi-* repo that hasn't picked up the Directory.Build.props change yet. Gate it behind the existing bypassPackageCheck input for repos not yet ready. --- src/Directory.Build.props | 3 ++ .../GetPackageManifestRequestHandler.cs | 32 ++++++++++++++----- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 73c3a7bb..ab9f2732 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -20,4 +20,7 @@ + + + diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index 9be5e78f..aa1663a4 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -93,7 +93,15 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers essentials.Name = name; } - if (string.IsNullOrEmpty(essentials.PackageId)) + // Prefer the PackageId embedded via Directory.Build.props' AssemblyMetadata item (the + // authoritative source, matching the actual published PackageId) over any config-supplied + // or hardcoded value. + var reflectedPackageId = GetAssemblyMetadataValue(essentialsAssembly, "PackageId"); + if (!string.IsNullOrEmpty(reflectedPackageId)) + { + essentials.PackageId = reflectedPackageId; + } + else if (string.IsNullOrEmpty(essentials.PackageId)) { essentials.PackageId = "PepperDash.Essentials"; } @@ -123,6 +131,11 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var reflectedRepoUrl = TrimTrailingGit(GetAssemblyMetadataValue(loaded.Assembly, "RepositoryUrl")); var reflectedName = GetAssemblyProduct(loaded.Assembly); + // Plugins built from a Directory.Build.props that embeds + // carry their PackageId + // directly - this is authoritative and should be preferred over the title/name fallback chain. + var reflectedPackageId = GetAssemblyMetadataValue(loaded.Assembly, "PackageId"); + var assemblyTitle = GetAssemblyTitle(loaded.Assembly); var assemblyName = loaded.Assembly.GetName().Name; var assemblyNameNoSeriesSuffix = StripTrailingSeriesSuffix(assemblyName); @@ -130,7 +143,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var match = configPackages.FirstOrDefault(p => !matchedConfigPackages.Contains(p) && !string.IsNullOrEmpty(p.PackageId) && - (string.Equals(p.PackageId, assemblyTitle, StringComparison.OrdinalIgnoreCase) || + (string.Equals(p.PackageId, reflectedPackageId, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyTitle, StringComparison.OrdinalIgnoreCase) || string.Equals(p.PackageId, assemblyName, StringComparison.OrdinalIgnoreCase) || string.Equals(p.PackageId, assemblyNameNoSeriesSuffix, StringComparison.OrdinalIgnoreCase))); @@ -140,20 +154,22 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers mergedPackages.Add(new NugetVersion { - PackageId = match.PackageId, - Version = reflectedVersion, + Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName, RepoUrl = !string.IsNullOrEmpty(match.RepoUrl) ? match.RepoUrl : reflectedRepoUrl, - Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName + PackageId = !string.IsNullOrEmpty(reflectedPackageId) ? reflectedPackageId : match.PackageId, + Version = reflectedVersion }); } else { - // Loaded but not present (or not matched) in config - emit without a packageId + // Loaded but not present (or not matched) in config - emit the reflected PackageId + // when the assembly carries one, otherwise leave it null as before. mergedPackages.Add(new NugetVersion { - Version = reflectedVersion, + Name = reflectedName, RepoUrl = reflectedRepoUrl, - Name = reflectedName + PackageId = reflectedPackageId, + Version = reflectedVersion, }); } } From 47e186d8f4ad4fd2a19aab460baab1c231be8f8d Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 16:55:43 -0500 Subject: [PATCH 35/66] fix(config): correctly detect v1 vs v2 config and preserve versions node - Determine config version by presence of "system" and "template" nodes instead of "versions", since a v2 config can also include "versions" and was previously being skipped from merging as a result. - Preserve the "versions" node after merging a v1 config, since PortalConfigReader.MergeConfigs does not carry it forward. --- .../Config/Essentials/ConfigReader.cs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs index 40c6c0cf..37cd4eba 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs @@ -135,12 +135,14 @@ namespace PepperDash.Essentials.Core.Config { var parsedConfig = JObject.Parse(fs.ReadToEnd()); - // Check if it's a v2 config (check for "version" node) - // this means it's already merged by the Portal API - // from the v2 config tool - var isV2Config = parsedConfig["versions"] != null; - - if (isV2Config) + // A config is v1 if it has separate "system" and "template" nodes that + // need to be merged. A v2 config is already merged by the Portal API and + // will not have "system"/"template" nodes. This is independent of whether + // a "versions" node is present, which only carries version metadata and + // can appear on either a v1 or v2 config. + var isV1Config = parsedConfig["system"] != null && parsedConfig["template"] != null; + + if (!isV1Config) { Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary."); ConfigObject = parsedConfig.ToObject(); @@ -148,6 +150,8 @@ namespace PepperDash.Essentials.Core.Config return true; } + Debug.LogMessage(LogEventLevel.Information, "Config file is a v1 format, merging system and template."); + // Extract SystemUrl and TemplateUrl into final config output ConfigObject = PortalConfigReader.MergeConfigs(parsedConfig).ToObject(); @@ -160,6 +164,13 @@ namespace PepperDash.Essentials.Core.Config { ConfigObject.TemplateUrl = parsedConfig["template_url"].Value(); } + + // MergeConfigs does not carry the "versions" node forward, so it must be + // applied separately to ensure it's preserved in the merged config. + if (parsedConfig["versions"] != null) + { + ConfigObject.Versions = parsedConfig["versions"].ToObject(); + } } Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config"); From 1e63b10aa4b0bd26742674e4c0edee1050855d57 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 17:14:34 -0500 Subject: [PATCH 36/66] fix: report main assembly's PackageId (PepperDashEssentials) in packageManifest API --- .../GetPackageManifestRequestHandler.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index aa1663a4..f2fac995 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -77,9 +77,15 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers essentials.Version = Global.AssemblyVersion; - // PepperDash_Essentials_Core.dll - same repo/Directory.Build.props as PepperDashEssentials.dll, - // and unlike PluginLoader.EssentialsAssembly, this Assembly reference is never null at runtime. - var essentialsAssembly = typeof(GetPackageManifestRequestHandler).Assembly; + // The main program assembly (AssemblyName/PackageId "PepperDashEssentials") is what's + // actually published to NuGet, but this handler lives in PepperDash.Essentials.Core, which + // can't reference that project's types directly (Essentials -> Core, not the reverse). + // PluginLoader.EssentialsAssembly.Assembly is unreliable (often left null - see + // PluginLoader.SetEssentialsAssembly), so look it up directly from the loaded AppDomain, + // falling back to this handler's own (Core) assembly if it can't be found. + var essentialsAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(a.GetName().Name, "PepperDashEssentials", StringComparison.OrdinalIgnoreCase)) + ?? typeof(GetPackageManifestRequestHandler).Assembly; var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl")); if (!string.IsNullOrEmpty(repoUrl)) @@ -103,7 +109,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers } else if (string.IsNullOrEmpty(essentials.PackageId)) { - essentials.PackageId = "PepperDash.Essentials"; + essentials.PackageId = "PepperDashEssentials"; } result.Essentials = essentials; From c323c872fc169c4f0529f7bcae5f9182ac4c8ca1 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 17:20:22 -0500 Subject: [PATCH 37/66] fix: match main assembly by PackageId metadata instead of AssemblyName --- .../GetPackageManifestRequestHandler.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index f2fac995..c250ff95 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -77,14 +77,15 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers essentials.Version = Global.AssemblyVersion; - // The main program assembly (AssemblyName/PackageId "PepperDashEssentials") is what's - // actually published to NuGet, but this handler lives in PepperDash.Essentials.Core, which - // can't reference that project's types directly (Essentials -> Core, not the reverse). - // PluginLoader.EssentialsAssembly.Assembly is unreliable (often left null - see - // PluginLoader.SetEssentialsAssembly), so look it up directly from the loaded AppDomain, - // falling back to this handler's own (Core) assembly if it can't be found. + // The main program assembly (PackageId "PepperDashEssentials") is what's actually published + // to NuGet, but this handler lives in PepperDash.Essentials.Core, which can't reference that + // project's types directly (Essentials -> Core, not the reverse). PluginLoader.EssentialsAssembly.Assembly + // is unreliable (often left null - see PluginLoader.SetEssentialsAssembly), so look it up + // directly from the loaded AppDomain by its Directory.Build.props-embedded PackageId metadata + // (every project's .csproj sets its own PackageId explicitly), falling back to this handler's + // own (Core) assembly if it can't be found. var essentialsAssembly = AppDomain.CurrentDomain.GetAssemblies() - .FirstOrDefault(a => string.Equals(a.GetName().Name, "PepperDashEssentials", StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(a => string.Equals(GetAssemblyMetadataValue(a, "PackageId"), "PepperDashEssentials", StringComparison.OrdinalIgnoreCase)) ?? typeof(GetPackageManifestRequestHandler).Assembly; var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl")); From 02216372bc14a097c5830ef2b1f754096f933e09 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 17:41:04 -0500 Subject: [PATCH 38/66] feat(mobile-control): track and validate UI client app versions Extend the /system/clientJoined handler to accept an optional `appVersion` field reported by connecting UI clients (e.g. the React app's build-time APP_VERSION), so Essentials can record and validate what's actually running against the configured versions.touchpanelWrapperApp version. - Add ConnectedClientVersionInfo to capture clientId, roomKey, touchpanelKey, reported/expected app version, and last-seen time. - Add MobileControlSystemController.TrackClientAppVersion(), storing results in a new thread-safe ConnectedClientVersions dictionary and logging a warning on version mismatch. - Expose ConnectedClientVersions as a public read-only property for diagnostics. - Surface reported vs. expected versions per client in the `mobileinfo` console command output. No wire protocol changes required; content is passed as-is over the existing clientJoined message. --- .../ConnectedClientVersionInfo.cs | 47 ++++++++++ .../MobileControlSystemController.cs | 89 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs diff --git a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs new file mode 100644 index 00000000..4107d120 --- /dev/null +++ b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs @@ -0,0 +1,47 @@ +using System; +using Newtonsoft.Json; + +namespace PepperDash.Essentials +{ + /// + /// Represents the version information reported by a connected Mobile Control UI client + /// + public class ConnectedClientVersionInfo + { + /// + /// Gets or sets the client id + /// + [JsonProperty("clientId")] + public string ClientId { get; set; } + + /// + /// Gets or sets the room key the client joined + /// + [JsonProperty("roomKey")] + public string RoomKey { get; set; } + + /// + /// Gets or sets the touchpanel key the client joined as, if any + /// + [JsonProperty("touchpanelKey")] + public string TouchpanelKey { get; set; } + + /// + /// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION) + /// + [JsonProperty("appVersion")] + public string AppVersion { get; set; } + + /// + /// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured + /// + [JsonProperty("expectedAppVersion")] + public string ExpectedAppVersion { get; set; } + + /// + /// Gets or sets the UTC time the client last reported this version + /// + [JsonProperty("lastSeen")] + public DateTime LastSeen { get; set; } + } +} diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index 3d466f5b..91fc55c5 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -69,11 +69,32 @@ namespace PepperDash.Essentials private readonly Dictionary _defaultMessengers = new Dictionary(); + private readonly Dictionary _connectedClientVersions = + new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + private readonly object _connectedClientVersionsLock = new object(); + /// /// Get the custom messengers with subscriptions /// public ReadOnlyDictionary Messengers => new ReadOnlyDictionary(_messengers.Values.OfType().ToDictionary(k => k.Key, v => v)); + /// + /// Gets the most recently reported UI app version for each connected client, keyed by clientId + /// + public ReadOnlyDictionary ConnectedClientVersions + { + get + { + lock (_connectedClientVersionsLock) + { + return new ReadOnlyDictionary( + new Dictionary(_connectedClientVersions) + ); + } + } + } + /// /// Get the default messengers /// @@ -1782,6 +1803,28 @@ namespace PepperDash.Essentials " Not Enabled in Config.\r\n" ); } + + var connectedClientVersions = ConnectedClientVersions; + + if (connectedClientVersions.Count == 0) + { + CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions: None reported yet\r\n"); + } + else + { + CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions:\r\n"); + foreach (var kv in connectedClientVersions) + { + var v = kv.Value; + var match = string.IsNullOrEmpty(v.ExpectedAppVersion) || string.Equals(v.ExpectedAppVersion, v.AppVersion, StringComparison.OrdinalIgnoreCase); + + CrestronConsole.ConsoleCommandResponse( + $" Client: {v.ClientId} Touchpanel: {v.TouchpanelKey} Room: {v.RoomKey}\r\n" + + $" Reported: {v.AppVersion} Expected: {v.ExpectedAppVersion ?? "(not configured)"} Match: {(match ? "Yes" : "NO - MISMATCH")}\r\n" + + $" Last Seen (UTC): {v.LastSeen:yyyy-MM-dd HH:mm:ss}\r\n" + ); + } + } } /// @@ -2181,6 +2224,8 @@ namespace PepperDash.Essentials var roomKey = content["roomKey"].Value(); var touchpanelKey = content.SelectToken("touchpanelKey"); + TrackClientAppVersion(clientId, roomKey, touchpanelKey?.Value(), content.SelectToken("appVersion")?.Value()); + if (_roomCombiner == null) { var message = new MobileControlMessage @@ -2252,6 +2297,50 @@ namespace PepperDash.Essentials SendTouchpanelKey(clientId, touchpanelKey); } + /// + /// Records the app version reported by a connecting UI client (e.g. the mobile control React app's + /// build-time APP_VERSION) and compares it against the configured versions.touchpanelWrapperApp version. + /// + private void TrackClientAppVersion(string clientId, string roomKey, string touchpanelKey, string appVersion) + { + if (string.IsNullOrEmpty(appVersion)) + { + return; + } + + var expectedVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version; + + var info = new ConnectedClientVersionInfo + { + ClientId = clientId, + RoomKey = roomKey, + TouchpanelKey = touchpanelKey, + AppVersion = appVersion, + ExpectedAppVersion = expectedVersion, + LastSeen = DateTime.UtcNow + }; + + lock (_connectedClientVersionsLock) + { + _connectedClientVersions[clientId] = info; + } + + if (!string.IsNullOrEmpty(expectedVersion) && !string.Equals(expectedVersion, appVersion, StringComparison.OrdinalIgnoreCase)) + { + this.LogWarning( + "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}, which does not match configured versions.touchpanelWrapperApp version {expectedVersion}", + clientId, touchpanelKey, appVersion, expectedVersion + ); + } + else + { + this.LogVerbose( + "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}", + clientId, touchpanelKey, appVersion + ); + } + } + private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken) { if (touchpanelKeyToken == null) From 22ef9cf7c226139a22ba2526aa0f7668656212db Mon Sep 17 00:00:00 2001 From: Jason DeVito Date: Wed, 8 Jul 2026 09:54:14 -0500 Subject: [PATCH 39/66] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Web/RequestHandlers/GetPackageManifestRequestHandler.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index c250ff95..8dc4a751 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -46,8 +46,9 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers context.Response.Write(js, false); context.Response.End(); } - catch (Exception) + catch (Exception ex) { + PepperDash.Core.Debug.LogMessage(ex, "Exception handling GET /packageManifest request"); context.Response.StatusCode = 500; context.Response.StatusDescription = "Internal Server Error"; context.Response.End(); From 02f507ccb11a7cab2e5c69935a5fb49ffaa1bac6 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Wed, 8 Jul 2026 10:07:20 -0500 Subject: [PATCH 40/66] fix(mobile-control): address PR review feedback on version tracking - ConnectedClientVersions now deep-copies each ConnectedClientVersionInfo when snapshotting, so external callers can't mutate the internally tracked, lock-protected instances (Copilot review). - ShowInfo now prints "(not configured)" for an empty ExpectedAppVersion, matching the match/mismatch calculation which already treats empty the same as not-configured (Copilot review). - GetPackageManifestRequestHandler.PopulatePackages filters out null entries from the config-supplied packages list before processing, so a malformed "packages": [null, ...] in user-edited config JSON degrades gracefully instead of throwing (Copilot review). --- .../GetPackageManifestRequestHandler.cs | 6 +++++- .../ConnectedClientVersionInfo.cs | 14 ++++++++++++++ .../MobileControlSystemController.cs | 4 ++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index 8dc4a751..eeea75cd 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -122,7 +122,11 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// private static void PopulatePackages(VersionData result) { - var configPackages = result.Packages ?? new System.Collections.Generic.List(); + // Filter out null entries defensively - the packages list is deserialized from user-editable + // config JSON, so a malformed "packages": [null, ...] shouldn't throw and 500 the endpoint. + var configPackages = (result.Packages ?? new System.Collections.Generic.List()) + .Where(p => p != null) + .ToList(); var matchedConfigPackages = new System.Collections.Generic.HashSet(); var mergedPackages = new System.Collections.Generic.List(); diff --git a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs index 4107d120..68f5e36e 100644 --- a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs +++ b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs @@ -43,5 +43,19 @@ namespace PepperDash.Essentials /// [JsonProperty("lastSeen")] public DateTime LastSeen { get; set; } + + /// + /// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate + /// without affecting the internally tracked instance + /// + public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo + { + ClientId = ClientId, + RoomKey = RoomKey, + TouchpanelKey = TouchpanelKey, + AppVersion = AppVersion, + ExpectedAppVersion = ExpectedAppVersion, + LastSeen = LastSeen + }; } } diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index 91fc55c5..c17cf3b9 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -89,7 +89,7 @@ namespace PepperDash.Essentials lock (_connectedClientVersionsLock) { return new ReadOnlyDictionary( - new Dictionary(_connectedClientVersions) + _connectedClientVersions.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()) ); } } @@ -1820,7 +1820,7 @@ namespace PepperDash.Essentials CrestronConsole.ConsoleCommandResponse( $" Client: {v.ClientId} Touchpanel: {v.TouchpanelKey} Room: {v.RoomKey}\r\n" + - $" Reported: {v.AppVersion} Expected: {v.ExpectedAppVersion ?? "(not configured)"} Match: {(match ? "Yes" : "NO - MISMATCH")}\r\n" + + $" Reported: {v.AppVersion} Expected: {(string.IsNullOrEmpty(v.ExpectedAppVersion) ? "(not configured)" : v.ExpectedAppVersion)} Match: {(match ? "Yes" : "NO - MISMATCH")}\r\n" + $" Last Seen (UTC): {v.LastSeen:yyyy-MM-dd HH:mm:ss}\r\n" ); } From a8ec83106450d32eef4d9006fd851e295345e37d Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Mon, 13 Jul 2026 12:58:52 -0700 Subject: [PATCH 41/66] feat: add IHasDspPresetSave interface for DSP preset management --- .../Devices/IHasDspPresetSave.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs diff --git a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs new file mode 100644 index 00000000..3cdd348b --- /dev/null +++ b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs @@ -0,0 +1,14 @@ +namespace PepperDash.Essentials.Core +{ + /// + /// Defines the contract for IHasDspPresetSave + /// + public interface IHasDspPresetSave : IDspPresets // recall + save + { + /// + /// Saves the DSP preset by key + /// + /// + void SavePresetByKey(string presetKey); // mirrors RecallPreset(string key) + } +} \ No newline at end of file From d4284bd59f2137a10cd044038f32e599cc680dd7 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Mon, 13 Jul 2026 16:08:10 -0700 Subject: [PATCH 42/66] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs index 3cdd348b..fc71d28d 100644 --- a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs +++ b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs @@ -3,7 +3,7 @@ namespace PepperDash.Essentials.Core /// /// Defines the contract for IHasDspPresetSave /// - public interface IHasDspPresetSave : IDspPresets // recall + save + public interface IHasDspPresetSave : IDspPresets { /// /// Saves the DSP preset by key From 4dca16e9bd0fac08fe6d2c79650516bd74a0cc29 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Mon, 13 Jul 2026 16:09:45 -0700 Subject: [PATCH 43/66] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs index fc71d28d..c628129e 100644 --- a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs +++ b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs @@ -6,9 +6,9 @@ namespace PepperDash.Essentials.Core public interface IHasDspPresetSave : IDspPresets { /// - /// Saves the DSP preset by key + /// Saves the preset by key /// - /// - void SavePresetByKey(string presetKey); // mirrors RecallPreset(string key) + /// key of preset to save + void SavePreset(string key); } } \ No newline at end of file From 707195b8c5544b449846ae7da2475da5b3997c03 Mon Sep 17 00:00:00 2001 From: equinoy Date: Tue, 14 Jul 2026 19:48:32 -0500 Subject: [PATCH 44/66] 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; From ae01c59b975e4a751cf251433a60b61c978fdbea Mon Sep 17 00:00:00 2001 From: equinoy Date: Wed, 15 Jul 2026 09:52:07 -0500 Subject: [PATCH 45/66] 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 b485eade..0ad097ca 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,38 @@ Utilization of Essentials Framework falls into the following categories: For detailed documentation, see the [Wiki](https://github.com/PepperDash/EssentialsFramework/wiki). +## Room Combiner Operation Lifecycle + +Essentials room combiner now exposes an operation lifecycle that clients can use to show progress and terminal status during scenario changes. + +Lifecycle states: +- Idle +- InProgress +- Completed +- Failed +- TimedOut + +The lifecycle is exposed on room combiners that implement the operation-status extension and is included in app server combiner messages under the combinationOperation payload. + +Configuration: +- combinationOperationTimeoutSeconds (optional): timeout in seconds for an in-progress operation. +- Default when omitted: 300 seconds. +- Values less than or equal to 0 are treated as invalid and fall back to the default. + +Example room combiner properties: + +```json +{ + "defaultToManualMode": false, + "defaultScenarioKey": "divided", + "combinationOperationTimeoutSeconds": 30, + "roomKeys": ["roomA", "roomB"] +} +``` + +Operational note: +- When an operation reaches a terminal state (Completed, Failed, TimedOut), the operation timeout timer is stopped. + ## Support * Check out our [Discord Server](https://discord.gg/rWyeRH3K) 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 46/66] 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 13287c72..4a7328a9 100644 --- a/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs +++ b/src/PepperDash.Essentials.Core/Room/Combining/EssentialsRoomCombinerPropertiesConfig.cs @@ -13,7 +13,7 @@ namespace PepperDash.Essentials.Core { /// /// 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 47/66] 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 6cd3835e..3e683ac5 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs @@ -1,5 +1,5 @@ using System; - +using PepperDash.Core; namespace PepperDash.Essentials.Core.DeviceTypeInterfaces { /// 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 48/66] 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 7d947e19..9831fa91 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs @@ -52,20 +52,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var device = DeviceManager.GetDeviceForKey(deviceObj.ToString()) as IHasFeedback; if (device == null) { - context.Response.StatusCode = 200; - context.Response.StatusDescription = "OK"; - context.Response.ContentType = "application/json"; - context.Response.ContentEncoding = System.Text.Encoding.UTF8; - var resp = new - { - BoolValues = Array.Empty(), - 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 49/66] 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 59c662dc..b258632f 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -187,11 +187,12 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers _portForwardTimeoutTimer?.Dispose(); _portForwardTimeoutTimer = new CTimer(_ => { - if (Debug.WebsocketSink.HasActiveConnections) - { - Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward"); - 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 50/66] 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 ac4e80d8..4998a179 100644 --- a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs +++ b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs @@ -368,7 +368,6 @@ namespace PepperDash.Essentials.Core var key = args[1]; - provider.SetSecret(key, ""); response = provider.SetSecret(key, "") ? $"Secret successfully deleted for {provider.Key}:{key}" : $"Unable to delete secret for {provider.Key}:{key}"; 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 51/66] 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 ac4e80d8..4998a179 100644 --- a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs +++ b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs @@ -368,7 +368,6 @@ namespace PepperDash.Essentials.Core var key = args[1]; - provider.SetSecret(key, ""); response = provider.SetSecret(key, "") ? $"Secret successfully deleted for {provider.Key}:{key}" : $"Unable to delete secret for {provider.Key}:{key}"; From aa76551500380bd705e3dbe13c977e3e962a8556 Mon Sep 17 00:00:00 2001 From: Erik Meyer Date: Thu, 16 Jul 2026 08:40:33 -0400 Subject: [PATCH 52/66] fix: guard CecPortController against null StreamCec and lazily subscribe --- .../Comm and IR/CecPortController.cs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs index 298bbc9b..4b381dd0 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs @@ -39,6 +39,8 @@ namespace PepperDash.Essentials.Core ICec Port; + bool _cecSubscribed; + /// /// Constructor /// @@ -54,7 +56,7 @@ namespace PepperDash.Essentials.Core { Port = postActivationFunc(config); - Port.StreamCec.CecChange += StreamCec_CecChange; + TryEnsureCecSubscription(); }); } @@ -68,7 +70,29 @@ namespace PepperDash.Essentials.Core { Port = port; + TryEnsureCecSubscription(); + } + + /// + /// Subscribes to the CEC change event once is available. + /// Safe to call repeatedly; the subscription is only wired a single time. This allows the + /// receive path to self-heal if StreamCec was null at construction (e.g. the underlying + /// device had not yet come online). + /// + void TryEnsureCecSubscription() + { + if (_cecSubscribed) + return; + + if (Port?.StreamCec == null) + { + Debug.LogMessage(LogEventLevel.Warning, this, "StreamCec is not available; CEC feedback is deferred until the device is ready"); + return; + } + Port.StreamCec.CecChange += new CecChangeEventHandler(StreamCec_CecChange); + _cecSubscribed = true; + Debug.LogMessage(LogEventLevel.Information, this, "Subscribed to CEC feedback"); } void StreamCec_CecChange(Cec cecDevice, CecEventArgs args) @@ -104,8 +128,9 @@ namespace PepperDash.Essentials.Core /// public void SendText(string text) { - if (Port == null) + if (Port?.StreamCec == null) return; + TryEnsureCecSubscription(); this.PrintSentText(text); Port.StreamCec.Send.StringValue = text; } @@ -115,8 +140,9 @@ namespace PepperDash.Essentials.Core /// public void SendBytes(byte[] bytes) { - if (Port == null) + if (Port?.StreamCec == null) return; + TryEnsureCecSubscription(); var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); this.PrintSentBytes(bytes); Debug.LogMessage(LogEventLevel.Information, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes)); From f4cb17314d4bc8a82f0eb6b58a16779d736d4d31 Mon Sep 17 00:00:00 2001 From: erikdred <88980320+erikdred@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:21:45 -0400 Subject: [PATCH 53/66] 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 7d947e19..9831fa91 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs @@ -52,20 +52,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var device = DeviceManager.GetDeviceForKey(deviceObj.ToString()) as IHasFeedback; if (device == null) { - context.Response.StatusCode = 200; - context.Response.StatusDescription = "OK"; - context.Response.ContentType = "application/json"; - context.Response.ContentEncoding = System.Text.Encoding.UTF8; - var resp = new - { - BoolValues = Array.Empty(), - 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 fbf4c56403e9ec7ac51e0428ece3c4ad42201435 Mon Sep 17 00:00:00 2001 From: erikdred <88980320+erikdred@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:23:18 -0400 Subject: [PATCH 54/66] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Messengers/DeviceStateMessageBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs index 4241b69c..c5c0ab65 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs @@ -13,7 +13,7 @@ namespace PepperDash.Essentials.AppServer.Messengers /// The interfaces implmented by the device sending the messsage /// [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")] + [Obsolete("Interfaces are 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 36c15b8d959cd17c61399fd3fdd0190641a724cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:25:25 +0000 Subject: [PATCH 55/66] Clarify CecPortController subscription XML comment --- .../Comm and IR/CecPortController.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs index 4b381dd0..a5aa0d13 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs @@ -75,9 +75,9 @@ namespace PepperDash.Essentials.Core /// /// Subscribes to the CEC change event once is available. - /// Safe to call repeatedly; the subscription is only wired a single time. This allows the - /// receive path to self-heal if StreamCec was null at construction (e.g. the underlying - /// device had not yet come online). + /// Safe to call repeatedly; the subscription is only wired a single time. + /// If StreamCec is null during construction, this is retried when send methods invoke + /// this method later. /// void TryEnsureCecSubscription() { From 14cd08207621ee4666c6467e07ebba485fbe1817 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:26:24 +0000 Subject: [PATCH 56/66] Remove redundant IRoutingInputsOutputs filtering in route mapping loops --- src/PepperDash.Essentials.Core/Routing/Extensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 2b5b3c8c..303a0194 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -348,9 +348,9 @@ namespace PepperDash.Essentials.Core 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) { From 6949253b1a447f63dcb78a95dcf2e1920ec285be Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Wed, 29 Jul 2026 14:39:36 -0700 Subject: [PATCH 57/66] feat: add TS-1080 and TSW-1080 touchpanel support Add mctsw1080 and mcts1080 Mobile Control touchpanel types and map them to the Tsw1080 and Ts1080 hardware classes in the touchpanel controller factory. Bump the Crestron SimplSharp SDK packages (Library, ProgramLibrary, Program) from 2.21.90 to 2.21.274 across all projects. The Tsw1080 and Ts1080 device classes do not exist in the SDK prior to 2.21.274, so the SDK bump is required for the new panel types to compile. --- src/PepperDash.Core/PepperDash.Core.csproj | 2 +- .../PepperDash.Essentials.Core.csproj | 2 +- .../PepperDash.Essentials.Devices.Common.csproj | 2 +- .../PepperDash.Essentials.MobileControl.Messengers.csproj | 2 +- .../PepperDash.Essentials.MobileControl.csproj | 2 +- .../Touchpanel/MobileControlTouchpanelController.cs | 4 +++- src/PepperDash.Essentials/PepperDash.Essentials.csproj | 2 +- 7 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Core/PepperDash.Core.csproj b/src/PepperDash.Core/PepperDash.Core.csproj index daa5c6da..747aefb6 100644 --- a/src/PepperDash.Core/PepperDash.Core.csproj +++ b/src/PepperDash.Core/PepperDash.Core.csproj @@ -43,7 +43,7 @@ - + diff --git a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj index 251ba316..b7f333bd 100644 --- a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj +++ b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj @@ -25,7 +25,7 @@ bin\$(Configuration)\PepperDash_Essentials_Core.xml - + diff --git a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj index 7be4372d..317fa2d3 100644 --- a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj +++ b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj @@ -29,6 +29,6 @@ - + \ No newline at end of file diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj index d13d1a09..a9223061 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj +++ b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj @@ -33,7 +33,7 @@ - + diff --git a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj index 235e0899..b0e2dd9f 100644 --- a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj +++ b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj @@ -38,7 +38,7 @@ - + diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index 5830782d..9e6230d3 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -740,7 +740,7 @@ namespace PepperDash.Essentials.Touchpanel /// public MobileControlTouchpanelControllerFactory() { - TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mcxpanel", "mcdge1000" }; + TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mctsw1080", "mcts1080", "mcxpanel", "mcdge1000" }; MinimumEssentialsFrameworkVersion = "2.0.0"; factories = new Dictionary> @@ -765,6 +765,8 @@ namespace PepperDash.Essentials.Touchpanel {"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)}, {"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)}, {"ts1070", (id, controlSystem, projectName) => new Ts1070(id, controlSystem)}, + {"tsw1080", (id, controlSystem, projectName) => new Tsw1080(id, controlSystem)}, + {"ts1080", (id, controlSystem, projectName) => new Ts1080(id, controlSystem)}, {"dge1000", (id, controlSystem, projectName) => new Dge1000(id, controlSystem)} }; } diff --git a/src/PepperDash.Essentials/PepperDash.Essentials.csproj b/src/PepperDash.Essentials/PepperDash.Essentials.csproj index 20a42ffd..cb83bf84 100644 --- a/src/PepperDash.Essentials/PepperDash.Essentials.csproj +++ b/src/PepperDash.Essentials/PepperDash.Essentials.csproj @@ -48,7 +48,7 @@ - + From 1719de195f00f2a20c7cc10f24db82741c256891 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Thu, 30 Jul 2026 14:10:15 -0700 Subject: [PATCH 58/66] feat: implement ICommunicationMonitor on MobileControlTouchpanelController for Health page visibility --- .../MobileControlTouchpanelController.cs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index 5830782d..d3350ac9 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -25,11 +25,16 @@ namespace PepperDash.Essentials.Touchpanel /// Mobile Control touchpanel controller that provides app control, Zoom integration, /// and mobile control functionality for Crestron touchpanels. /// - public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme + public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme, ICommunicationMonitor { private readonly MobileControlTouchpanelProperties localConfig; private IMobileControlRoomMessenger _bridge; + /// + /// Gets the CommunicationMonitor tracking the panel's online/offline state + /// + public StatusMonitorBase CommunicationMonitor { get; private set; } + private string _appUrl; /// @@ -128,6 +133,11 @@ namespace PepperDash.Essentials.Touchpanel { localConfig = config; + if (panel != null) + { + CommunicationMonitor = new CrestronGenericBaseCommunicationMonitor(this, panel, 120000, 300000); + } + AddPostActivationAction(SubscribeForMobileControlUpdates); ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme); @@ -366,6 +376,8 @@ namespace PepperDash.Essentials.Touchpanel /// public override bool CustomActivate() { + CommunicationMonitor?.Start(); + var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this); var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this); @@ -393,6 +405,17 @@ namespace PepperDash.Essentials.Touchpanel return base.CustomActivate(); } + /// + /// Stops the CommunicationMonitor on deactivation. + /// + /// True if deactivation was successful; otherwise, false. + public override bool Deactivate() + { + CommunicationMonitor?.Stop(); + + return base.Deactivate(); + } + /// /// Handles device extender signal changes for system reserved signals. /// From d21cef0e60f25396a6adfb5d77543fb574884f80 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Thu, 30 Jul 2026 16:22:30 -0700 Subject: [PATCH 59/66] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Touchpanel/MobileControlTouchpanelController.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index d3350ac9..eef69015 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -35,6 +35,18 @@ namespace PepperDash.Essentials.Touchpanel /// public StatusMonitorBase CommunicationMonitor { get; private set; } + private sealed class NullCommunicationMonitor : StatusMonitorBase + { + public NullCommunicationMonitor(IKeyed parent) : base(parent, 120000, 300000) + { + Status = MonitorStatus.InError; + Message = "Panel is not initialized"; + } + + public override void Start() { } + public override void Stop() { } + } + private string _appUrl; /// From 3c9f5e95e0b6c0029d17e4c64757fa19418870bd Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 21 Jul 2026 21:50:06 -0500 Subject: [PATCH 60/66] feat(mobile-control): support https/wss for direct server URLs when Secure is true - Add HttpScheme/WsScheme helpers driven by directServer.Secure - Replace hardcoded http/ws literals in UserAppUrlPrefix, touchpanel app URL, _config.local.json ApiPath, remote logging POST, join-response WebSocketUrl and UserAppUrl - Pass Secure flag into the HttpServer constructor so the listener actually negotiates TLS when a cert is configured - Drop TLS 1.1 from EnabledSslProtocols, keep TLS 1.2 (TLS 1.3 is not defined in the net472 SslProtocols enum used by this project) - MobileControlTouchpanelController: rewrite the app-URL IP regex to match and preserve either http or https instead of assuming http --- .../MobileControlTouchpanelController.cs | 8 ++--- .../MobileControlWebsocketServer.cs | 31 +++++++++++++------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index ec241a33..dcd0d56e 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -554,15 +554,15 @@ namespace PepperDash.Essentials.Touchpanel return false; }) ? csIpAddress.ToString() : processorIp; - var match = Regex.Match(url, @"^http://([^:/]+):\d+/mc/app\?token=.+$"); + var match = Regex.Match(url, @"^(https?)://([^:/]+):\d+/mc/app\?token=.+$"); if (match.Success) { - string ipa = match.Groups[1].Value; + string ipa = match.Groups[2].Value; // ip will be "192.168.1.100" } - // replace ipa with ip but leave the rest of the string intact - var updatedUrl = Regex.Replace(url, @"^http://[^:/]+", $"http://{ip}"); + // replace the host but preserve whatever scheme (http/https) is already present in the URL + var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}"); this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl); diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index 1c9ed37a..3bcbcdf6 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -127,6 +127,16 @@ namespace PepperDash.Essentials.WebSocketServer /// public int Port { get; private set; } + /// + /// Gets the HTTP scheme to use for generated URLs, based on whether the direct server is configured as secure + /// + private string HttpScheme => _parent.Config.DirectServer.Secure ? "https" : "http"; + + /// + /// Gets the WebSocket scheme to use for generated URLs, based on whether the direct server is configured as secure + /// + private string WsScheme => _parent.Config.DirectServer.Secure ? "wss" : "ws"; + /// /// Gets the user app URL prefix /// @@ -134,7 +144,8 @@ namespace PepperDash.Essentials.WebSocketServer { get { - return string.Format("http://{0}:{1}{2}?token=", + return string.Format("{0}://{1}:{2}{3}?token=", + HttpScheme, CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), Port, _userAppBaseHref); @@ -273,7 +284,7 @@ namespace PepperDash.Essentials.WebSocketServer { base.Initialize(); - _server = new HttpServer(Port, false); + _server = new HttpServer(Port, _parent.Config.DirectServer.Secure); _server.OnGet += Server_OnGet; @@ -291,7 +302,7 @@ namespace PepperDash.Essentials.WebSocketServer { ClientCertificateRequired = false, CheckCertificateRevocation = false, - EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 + EnabledSslProtocols = SslProtocols.Tls12 }; } @@ -403,11 +414,11 @@ namespace PepperDash.Essentials.WebSocketServer ip = csIpAddress.ToString(); } - var appUrl = $"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"; + var appUrl = $"{HttpScheme}://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"; this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key); - touchpanel.Touchpanel.SetAppUrl($"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"); + touchpanel.Touchpanel.SetAppUrl(appUrl); } } @@ -487,7 +498,7 @@ namespace PepperDash.Essentials.WebSocketServer { var config = new MobileControlApplicationConfig { - ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port), + ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, _parent.Config.DirectServer.Port), GatewayAppPath = "", LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png", EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false, @@ -1098,7 +1109,7 @@ namespace PepperDash.Essentials.WebSocketServer res.StatusCode = 200; res.Close(); - var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") + var logRequest = new HttpRequestMessage(HttpMethod.Post, $"{HttpScheme}://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; @@ -1213,8 +1224,7 @@ namespace PepperDash.Essentials.WebSocketServer this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now); // Construct WebSocket URL with clientId query parameter - var wsProtocol = "ws"; - var wsUrl = $"{wsProtocol}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}"; + var wsUrl = $"{WsScheme}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}"; // Construct the response object JoinResponse jRes = new JoinResponse @@ -1226,7 +1236,8 @@ namespace PepperDash.Essentials.WebSocketServer Config = _parent.GetConfigWithPluginVersion(), CodeExpires = new DateTime().AddYears(1), UserCode = bridge.UserCode, - UserAppUrl = string.Format("http://{0}:{1}/mc/app", + UserAppUrl = string.Format("{0}://{1}:{2}/mc/app", + HttpScheme, CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), Port), WebSocketUrl = wsUrl, From 3afc73a4469cd9b3101d7a17f17406e724547964 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Thu, 13 Aug 2026 17:50:26 -0500 Subject: [PATCH 61/66] fix(mobile-control): address PR review feedback - Use the actual listening Port instead of raw DirectServer.Port config when building the app URL and API path, so advertised URLs match the listener even when the configured port is 0. - Keep the remote logging endpoint on http:// since there is no dedicated secure flag for the log collector. - Remove dead Regex.Match/ipa variable in GetUrlWithCorrectIp; the scheme-preserving Regex.Replace below it already handles this. --- .../Touchpanel/MobileControlTouchpanelController.cs | 7 ------- .../WebSocketServer/MobileControlWebsocketServer.cs | 7 ++++--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index dcd0d56e..262cb34e 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -554,13 +554,6 @@ namespace PepperDash.Essentials.Touchpanel return false; }) ? csIpAddress.ToString() : processorIp; - var match = Regex.Match(url, @"^(https?)://([^:/]+):\d+/mc/app\?token=.+$"); - if (match.Success) - { - string ipa = match.Groups[2].Value; - // ip will be "192.168.1.100" - } - // replace the host but preserve whatever scheme (http/https) is already present in the URL var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}"); diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index 3bcbcdf6..a948d857 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -414,7 +414,7 @@ namespace PepperDash.Essentials.WebSocketServer ip = csIpAddress.ToString(); } - var appUrl = $"{HttpScheme}://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"; + var appUrl = $"{HttpScheme}://{ip}:{Port}/mc/app?token={touchpanel.Key}"; this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key); @@ -498,7 +498,7 @@ namespace PepperDash.Essentials.WebSocketServer { var config = new MobileControlApplicationConfig { - ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, _parent.Config.DirectServer.Port), + ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, Port), GatewayAppPath = "", LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png", EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false, @@ -1109,7 +1109,8 @@ namespace PepperDash.Essentials.WebSocketServer res.StatusCode = 200; res.Close(); - var logRequest = new HttpRequestMessage(HttpMethod.Post, $"{HttpScheme}://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") + // remote log collector has no dedicated secure flag; keep it on http regardless of DirectServer.Secure + var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; From 85a4b635f69e9427c14d3cac9a93188dad2c3f5a Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Wed, 19 Aug 2026 10:24:23 -0700 Subject: [PATCH 62/66] fix(mobile-control): update version and improve response caching for join requests --- src/Directory.Build.props | 2 +- .../WebSocketServer/MobileControlWebsocketServer.cs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ab9f2732..235fee48 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 2.36.6-local + 2.42.1-local $(Version) PepperDash Technology PepperDash Technology diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index a948d857..c96dc116 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -1162,6 +1162,11 @@ namespace PepperDash.Essentials.WebSocketServer var qp = req.QueryString; var token = qp["token"]; + // Each join mints a single-use clientId; the panel webview must never replay a cached + // response, or it reconnects forever with an already-consumed id (1008 loop). + res.Headers.Add("Cache-Control", "no-store"); + res.Headers.Add("Pragma", "no-cache"); + this.LogVerbose("Join Room Request with token: {token}", token); byte[] body; @@ -1263,6 +1268,8 @@ namespace PepperDash.Essentials.WebSocketServer { res.StatusCode = 200; res.ContentType = "application/json"; + res.Headers.Add("Cache-Control", "no-store"); + res.Headers.Add("Pragma", "no-cache"); var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion }; var message = JsonConvert.SerializeObject(version); this.LogVerbose("{message}", message); From ec9753b19e824f5c9113004feca1397b489d46bc Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 20 Aug 2026 10:26:09 -0600 Subject: [PATCH 63/66] fix(ssh-client): improve error handling for SSH connection exceptions --- src/PepperDash.Core/Comm/GenericSshClient.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs index df44ab51..16406978 100644 --- a/src/PepperDash.Core/Comm/GenericSshClient.cs +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -287,13 +287,7 @@ namespace PepperDash.Core } catch (SshConnectionException e) { - var ie = e.InnerException; // The details are inside!! - - if (ie is SocketException) - { - this.LogError("CONNECTION failure: Cannot reach host"); - this.LogVerbose(ie, "Exception details: "); - } + var ie = e.InnerException; // The details are inside, when present - remote can close the connection with no inner exception at all if (ie is System.Net.Sockets.SocketException socketException) { @@ -301,15 +295,15 @@ namespace PepperDash.Core Hostname, Port); this.LogVerbose(socketException, "SocketException details: "); } - if (ie is SshAuthenticationException) + else if (ie is SshAuthenticationException) { this.LogError("Authentication failure for username {userName}", Username); this.LogVerbose(ie, "AuthenticationException details: "); } else { - this.LogError("Error on connect: {error}", ie.Message); - this.LogVerbose(ie, "Exception details: "); + this.LogError("Error on connect: {error}", ie?.Message ?? e.Message); + this.LogVerbose(ie ?? (Exception)e, "Exception details: "); } disconnectLogged = true; From 44e5ff4c3e04b343c856cb4537048ba2cc4c4f47 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 20 Aug 2026 10:47:30 -0600 Subject: [PATCH 64/66] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericSshClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs index 16406978..5a83fcad 100644 --- a/src/PepperDash.Core/Comm/GenericSshClient.cs +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -303,7 +303,7 @@ namespace PepperDash.Core else { this.LogError("Error on connect: {error}", ie?.Message ?? e.Message); - this.LogVerbose(ie ?? (Exception)e, "Exception details: "); + this.LogVerbose(ie ?? e, "Exception details: "); } disconnectLogged = true; From a55e4811b3e64d43015d00d9e9d0cc8461844076 Mon Sep 17 00:00:00 2001 From: Robert Sanders Date: Thu, 20 Aug 2026 16:18:04 -0400 Subject: [PATCH 65/66] fix(core-ssh): stop reconnect loop during stopprog --- src/PepperDash.Core/Comm/GenericSshClient.cs | 35 ++++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs index 5a83fcad..a758378f 100644 --- a/src/PepperDash.Core/Comm/GenericSshClient.cs +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -151,6 +151,8 @@ namespace PepperDash.Core // Thread-safety lock for state changes private readonly object _stateLock = new object(); + private volatile bool _isProgramStopping; + private bool disconnectLogged = false; /// @@ -207,11 +209,9 @@ namespace PepperDash.Core { if (programEventType == eProgramStatusEventType.Stopping) { - if (client != null) - { - this.LogDebug("Program stopping. Closing connection"); - Disconnect(); - } + _isProgramStopping = true; + this.LogDebug("Program stopping. Closing connection"); + Disconnect(); } } @@ -230,6 +230,13 @@ namespace PepperDash.Core ConnectEnabled = true; + if (_isProgramStopping) + { + this.LogDebug("Skipping connect because program is stopping"); + ConnectEnabled = false; + return; + } + try { connectLock.Wait(); @@ -308,7 +315,7 @@ namespace PepperDash.Core disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -320,7 +327,7 @@ namespace PepperDash.Core disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -332,7 +339,7 @@ namespace PepperDash.Core this.LogVerbose(e, "Exception details: "); disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -467,7 +474,7 @@ namespace PepperDash.Core { connectLock.Release(); } - if (AutoReconnect && ConnectEnabled) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -510,7 +517,10 @@ namespace PepperDash.Core this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim()); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - StartReconnectTimer(); + if (ConnectEnabled && !_isProgramStopping) + { + StartReconnectTimer(); + } } catch (Exception ex) { @@ -543,7 +553,10 @@ namespace PepperDash.Core this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes)); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - StartReconnectTimer(); + if (ConnectEnabled && !_isProgramStopping) + { + StartReconnectTimer(); + } } catch (Exception ex) { From 4ca59e3186c6404ec47f75c6c78c6916efb5dfc9 Mon Sep 17 00:00:00 2001 From: Robert Sanders Date: Thu, 20 Aug 2026 16:39:20 -0400 Subject: [PATCH 66/66] fix(core-ssh): harden GenericSshClient shutdown reconnect guards --- src/PepperDash.Core/Comm/GenericSshClient.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs index a758378f..546a2a67 100644 --- a/src/PepperDash.Core/Comm/GenericSshClient.cs +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -228,15 +228,14 @@ namespace PepperDash.Core return; } - ConnectEnabled = true; - if (_isProgramStopping) { this.LogDebug("Skipping connect because program is stopping"); - ConnectEnabled = false; return; } + ConnectEnabled = true; + try { connectLock.Wait(); @@ -517,7 +516,7 @@ namespace PepperDash.Core this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim()); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (ConnectEnabled && !_isProgramStopping) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { StartReconnectTimer(); } @@ -553,7 +552,7 @@ namespace PepperDash.Core this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes)); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (ConnectEnabled && !_isProgramStopping) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { StartReconnectTimer(); }