From c3511cd1a6c8e50fa6df2d49cd0d4dec2a03299d Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Tue, 9 Jun 2026 09:32:59 -0500 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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 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 06/29] 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 07/29] 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 08/29] 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 09/29] 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 10/29] 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 11/29] 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 12/29] 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 13/29] 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 14/29] 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 2f7789b374bf91112665f3f97e1a4b07bcd1b5f0 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 16:22:23 -0500 Subject: [PATCH 15/29] 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 16/29] 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 17/29] 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 18/29] 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 19/29] 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 20/29] 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 21/29] 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 22/29] 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 23/29] 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 24/29] 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 aa76551500380bd705e3dbe13c977e3e962a8556 Mon Sep 17 00:00:00 2001 From: Erik Meyer Date: Thu, 16 Jul 2026 08:40:33 -0400 Subject: [PATCH 25/29] 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 26/29] 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 27/29] 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 28/29] 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 29/29] 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) {