From d0fe225bbc2455f8d7428d69a1979c98c34723fc Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Wed, 14 Jan 2026 09:54:44 -0600 Subject: [PATCH 01/46] feat: improve routing feedback manager * Performance improvment by mapping out midpoint to sinks on startup * Use existing routing methods * Debounce event handling * Check all signal types for route updates --- .../Routing/RoutingFeedbackManager.cs | 341 +++++++++++++----- 1 file changed, 256 insertions(+), 85 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs index e37d2d57..61dfcbab 100644 --- a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs +++ b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Linq; +using Crestron.SimplSharp; using PepperDash.Core; using PepperDash.Essentials.Core.Config; @@ -11,6 +13,21 @@ namespace PepperDash.Essentials.Core.Routing /// public class RoutingFeedbackManager : EssentialsDevice { + /// + /// Maps midpoint device keys to the set of sink device keys that are downstream + /// + private Dictionary> midpointToSinksMap; + + /// + /// Debounce timers for each sink device to prevent rapid successive updates + /// + private readonly Dictionary updateTimers = new Dictionary(); + + /// + /// Debounce delay in milliseconds + /// + private const long DEBOUNCE_MS = 500; + /// /// Initializes a new instance of the class. /// @@ -19,10 +36,100 @@ namespace PepperDash.Essentials.Core.Routing public RoutingFeedbackManager(string key, string name) : base(key, name) { + AddPreActivationAction(BuildMidpointSinkMap); AddPreActivationAction(SubscribeForMidpointFeedback); AddPreActivationAction(SubscribeForSinkFeedback); } + /// + /// Builds a map of which sink devices are downstream of each midpoint device + /// for performance optimization in HandleMidpointUpdate + /// + private void BuildMidpointSinkMap() + { + midpointToSinksMap = new Dictionary>(); + + var sinks = DeviceManager.AllDevices.OfType(); + var midpoints = DeviceManager.AllDevices.OfType(); + + foreach (var sink in sinks) + { + if (sink.CurrentInputPort == null) + continue; + + // Find all upstream midpoints for this sink + var upstreamMidpoints = GetUpstreamMidpoints(sink); + + foreach (var midpointKey in upstreamMidpoints) + { + if (!midpointToSinksMap.ContainsKey(midpointKey)) + midpointToSinksMap[midpointKey] = new HashSet(); + + midpointToSinksMap[midpointKey].Add(sink.Key); + } + } + + Debug.LogMessage( + Serilog.Events.LogEventLevel.Information, + "Built midpoint-to-sink map with {count} midpoints", + this, + midpointToSinksMap.Count + ); + } + + /// + /// Gets all upstream midpoint device keys for a given sink + /// + private HashSet GetUpstreamMidpoints(IRoutingSinkWithSwitchingWithInputPort sink) + { + var result = new HashSet(); + var visited = new HashSet(); + + if (sink.CurrentInputPort == null) + return result; + + var tieLine = TieLineCollection.Default.FirstOrDefault(tl => + tl.DestinationPort.Key == sink.CurrentInputPort.Key && + tl.DestinationPort.ParentDevice.Key == sink.CurrentInputPort.ParentDevice.Key); + + if (tieLine == null) + return result; + + TraceUpstreamMidpoints(tieLine, result, visited); + return result; + } + + /// + /// Recursively traces upstream to find all midpoint devices + /// + private void TraceUpstreamMidpoints(TieLine tieLine, HashSet midpoints, HashSet visited) + { + if (tieLine == null || visited.Contains(tieLine.SourcePort.ParentDevice.Key)) + return; + + visited.Add(tieLine.SourcePort.ParentDevice.Key); + + if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint) + { + midpoints.Add(midpoint.Key); + + // Find upstream TieLines connected to this midpoint's inputs + var midpointInputs = (midpoint as IRoutingInputs)?.InputPorts; + if (midpointInputs != null) + { + foreach (var inputPort in midpointInputs) + { + var upstreamTieLine = TieLineCollection.Default.FirstOrDefault(tl => + tl.DestinationPort.Key == inputPort.Key && + tl.DestinationPort.ParentDevice.Key == inputPort.ParentDevice.Key); + + if (upstreamTieLine != null) + TraceUpstreamMidpoints(upstreamTieLine, midpoints, visited); + } + } + } + } + /// /// Subscribes to the RouteChanged event on all devices implementing . /// @@ -52,7 +159,7 @@ namespace PepperDash.Essentials.Core.Routing /// /// Handles the RouteChanged event from a midpoint device. - /// Triggers an update for all sink devices. + /// Only triggers updates for sink devices that are downstream of this midpoint. /// /// The midpoint device that reported a route change. /// The descriptor of the new route. @@ -63,12 +170,33 @@ namespace PepperDash.Essentials.Core.Routing { try { - var devices = - DeviceManager.AllDevices.OfType(); - - foreach (var device in devices) + // Only update affected sinks (performance optimization) + if (midpointToSinksMap != null && midpointToSinksMap.TryGetValue(midpoint.Key, out var affectedSinkKeys)) { - UpdateDestination(device, device.CurrentInputPort); + Debug.LogMessage( + Serilog.Events.LogEventLevel.Debug, + "Midpoint {midpoint} changed, updating {count} downstream sinks", + this, + midpoint.Key, + affectedSinkKeys.Count + ); + + foreach (var sinkKey in affectedSinkKeys) + { + if (DeviceManager.GetDeviceForKey(sinkKey) is IRoutingSinkWithSwitchingWithInputPort sink) + { + UpdateDestination(sink, sink.CurrentInputPort); + } + } + } + else + { + Debug.LogMessage( + Serilog.Events.LogEventLevel.Debug, + "Midpoint {midpoint} changed but has no downstream sinks in map", + this, + midpoint.Key + ); } } catch (Exception ex) @@ -113,6 +241,7 @@ namespace PepperDash.Essentials.Core.Routing /// /// Updates the CurrentSourceInfo and CurrentSourceInfoKey properties on a destination (sink) device /// based on its currently selected input port by tracing the route back through tie lines. + /// Uses debouncing to prevent rapid successive updates. /// /// The destination sink device to update. /// The currently selected input port on the destination device. @@ -120,6 +249,55 @@ namespace PepperDash.Essentials.Core.Routing IRoutingSinkWithSwitching destination, RoutingInputPort inputPort ) + { + if (destination == null) + return; + + var key = destination.Key; + + // Cancel existing timer for this sink + if (updateTimers.TryGetValue(key, out var existingTimer)) + { + existingTimer.Stop(); + existingTimer.Dispose(); + } + + // Start new debounced timer + updateTimers[key] = new CTimer(_ => + { + try + { + UpdateDestinationImmediate(destination, inputPort); + } + catch (Exception ex) + { + Debug.LogMessage( + ex, + "Error in debounced update for destination {destinationKey}: {message}", + this, + destination.Key, + ex.Message + ); + } + finally + { + if (updateTimers.ContainsKey(key)) + { + updateTimers[key]?.Dispose(); + updateTimers.Remove(key); + } + } + }, null, DEBOUNCE_MS); + } + + /// + /// Immediately updates the CurrentSourceInfo for a destination device. + /// Called after debounce delay. + /// + private void UpdateDestinationImmediate( + IRoutingSinkWithSwitching destination, + RoutingInputPort inputPort + ) { Debug.LogMessage( Serilog.Events.LogEventLevel.Debug, @@ -309,105 +487,98 @@ namespace PepperDash.Essentials.Core.Routing } /// - /// Recursively traces a route back from a given tie line to find the root source tie line. - /// It navigates through midpoint devices () by checking their current routes. + /// Traces a route back from a given tie line to find the root source tie line. + /// Leverages the existing Extensions.GetRouteToSource method with loop protection. /// /// The starting tie line (typically connected to a sink or midpoint). /// The connected to the original source device, or null if the source cannot be determined. private TieLine GetRootTieLine(TieLine tieLine) { - TieLine nextTieLine = null; try { - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "**Following tieLine {tieLine}**", this, tieLine); - - if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint) + if (!(tieLine.DestinationPort.ParentDevice is IRoutingInputs sink)) { - // Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "TieLine Source device {sourceDevice} is midpoint", this, midpoint); - - if (midpoint.CurrentRoutes == null || midpoint.CurrentRoutes.Count == 0) - { - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, - "Midpoint {midpointKey} has no routes", - this, - midpoint.Key - ); - return null; - } - - var currentRoute = midpoint.CurrentRoutes.FirstOrDefault(route => - { - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Checking {route} against {tieLine}", this, route, tieLine); - - return route.OutputPort != null - && route.InputPort != null - && route.OutputPort?.Key == tieLine.SourcePort.Key - && route.OutputPort?.ParentDevice.Key - == tieLine.SourcePort.ParentDevice.Key; - }); - - if (currentRoute == null) - { - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, - "No route through midpoint {midpoint} for outputPort {outputPort}", - this, - midpoint.Key, - tieLine.SourcePort - ); - return null; - } - - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found currentRoute {currentRoute} through {midpoint}", this, currentRoute, midpoint); - - nextTieLine = TieLineCollection.Default.FirstOrDefault(tl => - { - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Checking {route} against {tieLine}", tl.DestinationPort.Key, currentRoute.InputPort.Key); - return tl.DestinationPort.Key == currentRoute.InputPort.Key - && tl.DestinationPort.ParentDevice.Key - == currentRoute.InputPort.ParentDevice.Key; - }); - - if (nextTieLine != null) - { - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found next tieLine {tieLine}. Walking the chain", this, nextTieLine); - return GetRootTieLine(nextTieLine); - } - - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found root tieLine {tieLine}", this,nextTieLine); - return nextTieLine; + Debug.LogMessage( + Serilog.Events.LogEventLevel.Debug, + "TieLine destination {device} is not IRoutingInputs", + this, + tieLine.DestinationPort.ParentDevice.Key + ); + return null; } - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "TieLIne Source Device {sourceDeviceKey} is IRoutingSource: {isIRoutingSource}", this, tieLine.SourcePort.ParentDevice.Key, tieLine.SourcePort.ParentDevice is IRoutingSource); - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "TieLine Source Device interfaces: {typeFullName}:{interfaces}", this, tieLine.SourcePort.ParentDevice.GetType().FullName, tieLine.SourcePort.ParentDevice.GetType().GetInterfaces().Select(i => i.Name)); + // Get all potential sources (devices that only have outputs, not inputs+outputs) + var sources = DeviceManager.AllDevices + .OfType() + .Where(s => !(s is IRoutingInputsOutputs)); - if ( - tieLine.SourcePort.ParentDevice is IRoutingSource - || tieLine.SourcePort.ParentDevice is IRoutingOutputs - ) //end of the chain + // Try each signal type that this TieLine supports + var signalTypes = new[] { - // Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found root: {tieLine}", this, tieLine); - return tieLine; + eRoutingSignalType.Audio, + eRoutingSignalType.Video, + eRoutingSignalType.AudioVideo, + eRoutingSignalType.SecondaryAudio, + eRoutingSignalType.UsbInput, + eRoutingSignalType.UsbOutput + }; + + foreach (var signalType in signalTypes) + { + if (!tieLine.Type.HasFlag(signalType)) + continue; + + foreach (var source in sources) + { + // Use the optimized route discovery with loop protection + var (route, _) = sink.GetRouteToSource( + source, + signalType, + tieLine.DestinationPort, + null + ); + + if (route != null && route.Routes != null && route.Routes.Count > 0) + { + // Found a valid route - return the source TieLine + var sourceTieLine = TieLineCollection.Default.FirstOrDefault(tl => + tl.SourcePort.ParentDevice.Key == source.Key && + tl.Type.HasFlag(signalType)); + + if (sourceTieLine != null) + { + Debug.LogMessage( + Serilog.Events.LogEventLevel.Debug, + "Found route from {source} to {sink} with {count} hops", + this, + source.Key, + sink.Key, + route.Routes.Count + ); + return sourceTieLine; + } + } + } } - nextTieLine = TieLineCollection.Default.FirstOrDefault(tl => - tl.DestinationPort.Key == tieLine.SourcePort.Key - && tl.DestinationPort.ParentDevice.Key == tieLine.SourcePort.ParentDevice.Key + Debug.LogMessage( + Serilog.Events.LogEventLevel.Debug, + "No route found to any source from {sink}", + this, + sink.Key ); - - if (nextTieLine != null) - { - return GetRootTieLine(nextTieLine); - } + return null; } catch (Exception ex) { - Debug.LogMessage(ex, "Error walking tieLines: {Exception}", this, ex); + Debug.LogMessage( + ex, + "Error getting root tieLine: {Exception}", + this, + ex + ); return null; } - - return null; } } } From d05ebecd7d09719f55016229cceb374a0657242f Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Wed, 14 Jan 2026 09:55:32 -0600 Subject: [PATCH 02/46] fix: getroutingports command now prints port types --- src/PepperDash.Essentials.Core/Devices/DeviceManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Devices/DeviceManager.cs b/src/PepperDash.Essentials.Core/Devices/DeviceManager.cs index c199773a..f32fe84e 100644 --- a/src/PepperDash.Essentials.Core/Devices/DeviceManager.cs +++ b/src/PepperDash.Essentials.Core/Devices/DeviceManager.cs @@ -436,14 +436,14 @@ namespace PepperDash.Essentials.Core CrestronConsole.ConsoleCommandResponse("Device {0} has {1} Input Ports:{2}", s, inputPorts.Count, CrestronEnvironment.NewLine); foreach (var routingInputPort in inputPorts) { - CrestronConsole.ConsoleCommandResponse("{0}{1}", routingInputPort.Key, CrestronEnvironment.NewLine); + CrestronConsole.ConsoleCommandResponse("key: {0} signalType: {1}{2}", routingInputPort.Key, routingInputPort.Type, CrestronEnvironment.NewLine); } } if (outputPorts == null) return; CrestronConsole.ConsoleCommandResponse("Device {0} has {1} Output Ports:{2}", s, outputPorts.Count, CrestronEnvironment.NewLine); foreach (var routingOutputPort in outputPorts) { - CrestronConsole.ConsoleCommandResponse("{0}{1}", routingOutputPort.Key, CrestronEnvironment.NewLine); + CrestronConsole.ConsoleCommandResponse("key: {0} signalType: {1}{2}", routingOutputPort.Key, routingOutputPort.Type, CrestronEnvironment.NewLine); } } From fb8216beedb94990f76495358d1a9c8917c9362a Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Wed, 14 Jan 2026 10:00:48 -0600 Subject: [PATCH 03/46] feat: map routes/tielines at startup and new console commands * visualizeroutes allows visualizing configured routes based on tielines and signal type * can be filtered by source key, destination key, and type, along with partial matches for source & destination keys * visualizecurrentroutes visualizes what Essentials says is currently routed by type * uses same filtering as visualizeroutes * improvements to how the routing algorithm works --- .../Routing/Extensions.cs | 282 ++++++++++++++++- .../Routing/RouteDescriptor.cs | 95 +----- .../Routing/RouteDescriptorCollection.cs | 112 +++---- .../Routing/RouteSwitchDescriptor.cs | 87 ++---- src/PepperDash.Essentials/ControlSystem.cs | 293 +++++++++++++++++- 5 files changed, 621 insertions(+), 248 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 5d09176f..72dd8d07 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -18,6 +18,20 @@ namespace PepperDash.Essentials.Core /// public static class Extensions { + + /// + /// A collection of RouteDescriptors for each signal type. + /// + public static readonly Dictionary RouteDescriptors = new Dictionary() + { + { eRoutingSignalType.Audio, new RouteDescriptorCollection() }, + { eRoutingSignalType.Video, new RouteDescriptorCollection() }, + { eRoutingSignalType.SecondaryAudio, new RouteDescriptorCollection() }, + { eRoutingSignalType.AudioVideo, new RouteDescriptorCollection() }, + { eRoutingSignalType.UsbInput, new RouteDescriptorCollection() }, + { eRoutingSignalType.UsbOutput, new RouteDescriptorCollection() } + }; + /// /// Stores pending route requests, keyed by the destination device key. /// Used primarily to handle routing requests while a device is cooling down. @@ -29,6 +43,105 @@ namespace PepperDash.Essentials.Core /// private static readonly GenericQueue routeRequestQueue = new GenericQueue("routingQueue"); + /// + /// Indexed lookup of TieLines by destination device key for faster queries. + /// + private static Dictionary> _tieLinesByDestination; + + /// + /// Indexed lookup of TieLines by source device key for faster queries. + /// + private static Dictionary> _tieLinesBySource; + + /// + /// Cache of failed route attempts to avoid re-checking impossible paths. + /// Format: "sourceKey|destKey|signalType" + /// + private static readonly HashSet _impossibleRoutes = new HashSet(); + + /// + /// Indexes all TieLines by source and destination device keys for faster lookups. + /// Should be called once at system startup after all TieLines are created. + /// + public static void IndexTieLines() + { + try + { + Debug.LogMessage(LogEventLevel.Information, "Indexing TieLines for faster route discovery"); + + _tieLinesByDestination = TieLineCollection.Default + .GroupBy(t => t.DestinationPort.ParentDevice.Key) + .ToDictionary(g => g.Key, g => g.ToList()); + + _tieLinesBySource = TieLineCollection.Default + .GroupBy(t => t.SourcePort.ParentDevice.Key) + .ToDictionary(g => g.Key, g => g.ToList()); + + Debug.LogMessage(LogEventLevel.Information, "TieLine indexing complete. {0} destination keys, {1} source keys", + null, _tieLinesByDestination.Count, _tieLinesBySource.Count); + } + catch (Exception ex) + { + Debug.LogError("Exception indexing TieLines: {exception}", ex.Message); + Debug.LogDebug(ex, "Stack Trace: "); + } + } + + /// + /// Gets TieLines connected to a destination device. + /// Uses indexed lookup if available, otherwise falls back to LINQ query. + /// + /// The destination device key + /// List of TieLines connected to the destination + private static IEnumerable GetTieLinesForDestination(string destinationKey) + { + if (_tieLinesByDestination != null && _tieLinesByDestination.TryGetValue(destinationKey, out List tieLines)) + { + return tieLines; + } + + // Fallback to LINQ if index not available + return TieLineCollection.Default.Where(t => t.DestinationPort.ParentDevice.Key == destinationKey); + } + + /// + /// Gets TieLines connected to a source device. + /// Uses indexed lookup if available, otherwise falls back to LINQ query. + /// + /// The source device key + /// List of TieLines connected to the source + private static IEnumerable GetTieLinesForSource(string sourceKey) + { + if (_tieLinesBySource != null && _tieLinesBySource.TryGetValue(sourceKey, out List tieLines)) + { + return tieLines; + } + + // Fallback to LINQ if index not available + return TieLineCollection.Default.Where(t => t.SourcePort.ParentDevice.Key == sourceKey); + } + + /// + /// Creates a cache key for route impossibility tracking. + /// + /// Source device key + /// Destination device key + /// Signal type + /// Cache key string + private static string GetRouteKey(string sourceKey, string destKey, eRoutingSignalType type) + { + return string.Format("{0}|{1}|{2}", sourceKey, destKey, type); + } + + /// + /// Clears the impossible routes cache. Should be called if TieLines are added/removed at runtime. + /// + public static void ClearImpossibleRoutesCache() + { + _impossibleRoutes.Clear(); + Debug.LogMessage(LogEventLevel.Information, "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 @@ -173,8 +286,9 @@ namespace PepperDash.Essentials.Core if (!audioSuccess && !videoSuccess) return (null, null); - - return (audioRouteDescriptor, videoRouteDescriptor); + // Return null for descriptors that have no routes + return (audioSuccess && audioRouteDescriptor.Routes.Count > 0 ? audioRouteDescriptor : null, + videoSuccess && videoRouteDescriptor.Routes.Count > 0 ? videoRouteDescriptor : null); } /// @@ -245,6 +359,90 @@ namespace PepperDash.Essentials.Core routeRequestQueue.Enqueue(new RouteRequestQueueItem(RunRouteRequest, routeRequest)); } + /// + /// Maps destination input ports to source output ports for all routing devices. + /// + public static void MapDestinationsToSources() + { + try + { + // Index TieLines before mapping if not already done + if (_tieLinesByDestination == null || _tieLinesBySource == null) + { + IndexTieLines(); + } + + var sinks = DeviceManager.AllDevices.OfType().Where(d => !(d is IRoutingInputsOutputs)); + var sources = DeviceManager.AllDevices.OfType().Where(d => !(d is IRoutingInputsOutputs)); + + foreach (var sink in sinks) + { + foreach (var source in sources) + { + foreach (var inputPort in sink.InputPorts) + { + foreach (var outputPort in source.OutputPorts) + { + var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, inputPort.Type, inputPort, outputPort); + + if (audioOrSingleRoute == null && videoRoute == null) + { + continue; + } + + if (audioOrSingleRoute != null) + { + // Only add routes that have actual switching steps + if (audioOrSingleRoute.Routes == null || audioOrSingleRoute.Routes.Count == 0) + { + continue; + } + + // Add to the appropriate collection(s) based on signal type + // Note: A single route descriptor with combined flags (e.g., AudioVideo) will be added once per matching signal type + if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Audio)) + { + RouteDescriptors[eRoutingSignalType.Audio].AddRouteDescriptor(audioOrSingleRoute); + } + if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Video)) + { + RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(audioOrSingleRoute); + } + if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.SecondaryAudio)) + { + RouteDescriptors[eRoutingSignalType.SecondaryAudio].AddRouteDescriptor(audioOrSingleRoute); + } + if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.UsbInput)) + { + RouteDescriptors[eRoutingSignalType.UsbInput].AddRouteDescriptor(audioOrSingleRoute); + } + if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.UsbOutput)) + { + RouteDescriptors[eRoutingSignalType.UsbOutput].AddRouteDescriptor(audioOrSingleRoute); + } + } + if (videoRoute != null) + { + // Only add routes that have actual switching steps + if (videoRoute.Routes == null || videoRoute.Routes.Count == 0) + { + continue; + } + + RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(videoRoute); + } + } + } + } + } + } + catch (Exception ex) + { + Debug.LogError("Exception mapping routes: {exception}", ex.Message); + Debug.LogDebug(ex, "Stack Trace: "); + } + } + /// /// Executes the actual routing based on a . /// Finds the route path, adds it to the collection, and executes the switches. @@ -257,7 +455,51 @@ namespace PepperDash.Essentials.Core if (request.Source == null) return; - var (audioOrSingleRoute, videoRoute) = request.Destination.GetRouteToSource(request.Source, request.SignalType, request.DestinationPort, request.SourcePort); + RouteDescriptor audioOrSingleRoute = null; + RouteDescriptor videoRoute = null; + + // Try to use pre-loaded route descriptors first + if (request.SignalType.HasFlag(eRoutingSignalType.AudioVideo)) + { + // For AudioVideo routes, check both Audio and Video collections + if (RouteDescriptors.TryGetValue(eRoutingSignalType.Audio, out RouteDescriptorCollection audioCollection)) + { + audioOrSingleRoute = audioCollection.Descriptors.FirstOrDefault(d => + d.Source.Key == request.Source.Key && + d.Destination.Key == request.Destination.Key && + (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key)); + } + + if (RouteDescriptors.TryGetValue(eRoutingSignalType.Video, out RouteDescriptorCollection videoCollection)) + { + videoRoute = videoCollection.Descriptors.FirstOrDefault(d => + d.Source.Key == request.Source.Key && + d.Destination.Key == request.Destination.Key && + (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key)); + } + } + else + { + // For single signal type routes + var signalTypeToCheck = request.SignalType.HasFlag(eRoutingSignalType.SecondaryAudio) + ? eRoutingSignalType.SecondaryAudio + : request.SignalType; + + if (RouteDescriptors.TryGetValue(signalTypeToCheck, out RouteDescriptorCollection collection)) + { + audioOrSingleRoute = collection.Descriptors.FirstOrDefault(d => + d.Source.Key == request.Source.Key && + d.Destination.Key == request.Destination.Key && + (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key)); + } + } + + // If no pre-loaded route found, build it dynamically + if (audioOrSingleRoute == null && videoRoute == null) + { + Debug.LogMessage(LogEventLevel.Debug, "No pre-loaded route found, building dynamically", request.Destination); + (audioOrSingleRoute, videoRoute) = request.Destination.GetRouteToSource(request.Source, request.SignalType, request.DestinationPort, request.SourcePort); + } if (audioOrSingleRoute == null && videoRoute == null) return; @@ -321,11 +563,13 @@ namespace PepperDash.Essentials.Core /// /// /// - /// The RoutingOutputPort whose link is being checked for a route + /// The RoutingOutputPort whose link is being checked for a route /// Prevents Devices from being twice-checked /// This recursive function should not be called with AudioVideo /// Just an informational counter /// The RouteDescriptor being populated as the route is discovered + /// The RoutingOutputPort whose link is being checked for a route + /// The source output port (optional) /// true if source is hit private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOutputs source, RoutingOutputPort outputPortToUse, List alreadyCheckedDevices, @@ -333,42 +577,54 @@ namespace PepperDash.Essentials.Core { cycle++; + // Check if this route has already been determined to be impossible + var routeKey = GetRouteKey(source.Key, destination.Key, signalType); + if (_impossibleRoutes.Contains(routeKey)) + { + Debug.LogMessage(LogEventLevel.Verbose, "Route {0} is cached as impossible, skipping", null, routeKey); + return false; + } + Debug.LogMessage(LogEventLevel.Verbose, "GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString()); RoutingInputPort goodInputPort = null; + // Use indexed lookup instead of LINQ query + var allDestinationTieLines = GetTieLinesForDestination(destination.Key); + IEnumerable destinationTieLines; TieLine directTie = null; if (destinationPort == null) { - destinationTieLines = TieLineCollection.Default.Where(t => - t.DestinationPort.ParentDevice.Key == destination.Key && (t.Type.HasFlag(signalType) || signalType == eRoutingSignalType.AudioVideo)); + destinationTieLines = allDestinationTieLines.Where(t => + t.Type.HasFlag(signalType) || signalType == eRoutingSignalType.AudioVideo); } else { - destinationTieLines = TieLineCollection.Default.Where(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.DestinationPort.Key == destinationPort.Key && (t.Type.HasFlag(signalType))); + destinationTieLines = allDestinationTieLines.Where(t => + t.DestinationPort.Key == destinationPort.Key && t.Type.HasFlag(signalType)); } // find the TieLine without a port if (destinationPort == null && sourcePort == null) { - directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.SourcePort.ParentDevice.Key == source.Key); + directTie = destinationTieLines.FirstOrDefault(t => t.SourcePort.ParentDevice.Key == source.Key); } // find a tieLine to a specific destination port without a specific source port else if (destinationPort != null && sourcePort == null) { - directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key); + directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key); } // find a tieline to a specific source port without a specific destination port else if (destinationPort == null & sourcePort != null) { - directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key); + directTie = destinationTieLines.FirstOrDefault(t => t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key); } // find a tieline to a specific source port and destination port else if (destinationPort != null && sourcePort != null) { - directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key); + directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key); } if (directTie != null) // Found a tie directly to the source @@ -423,6 +679,10 @@ namespace PepperDash.Essentials.Core if (goodInputPort == null) { Debug.LogMessage(LogEventLevel.Verbose, "No route found to {0}", destination, source.Key); + + // Cache this as an impossible route + _impossibleRoutes.Add(routeKey); + return false; } diff --git a/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs b/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs index aab82d38..c35b0afa 100644 --- a/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs +++ b/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs @@ -95,15 +95,15 @@ namespace PepperDash.Essentials.Core /// Releases the usage tracking for the route and optionally clears the route on the switching devices. /// /// If true, attempts to clear the route on the switching devices (e.g., set input to null/0). - - + + public void ReleaseRoutes(bool clearRoute = false) { foreach (var route in Routes.Where(r => r.SwitchingDevice is IRouting)) { if (route.SwitchingDevice is IRouting switchingDevice) { - if(clearRoute) + if (clearRoute) { try { @@ -137,98 +137,11 @@ namespace PepperDash.Essentials.Core /// Returns a string representation of the route descriptor, including source, destination, and individual route steps. /// /// A string describing the route. - - - public override string ToString() { var routesText = Routes.Select(r => r.ToString()).ToArray(); - return string.Format("Route table from {0} to {1}:\r{2}", Source.Key, Destination.Key, string.Join("\r", routesText)); + return $"Route table from {Source.Key} to {Destination.Key} for {SignalType}:\r\n {string.Join("\r\n ", routesText)}"; } } - /*/// - /// Represents an collection of individual route steps between Source and Destination - /// - /// - /// Represents a RouteDescriptor - /// - public class RouteDescriptor - { - /// - /// Gets or sets the Destination - /// - public IRoutingInputs Destination { get; private set; } - /// - /// Gets or sets the Source - /// - public IRoutingOutputs Source { get; private set; } - /// - /// Gets or sets the SignalType - /// - public eRoutingSignalType SignalType { get; private set; } - public List> Routes { get; private set; } - - - public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, eRoutingSignalType signalType) - { - Destination = destination; - Source = source; - SignalType = signalType; - Routes = new List>(); - } - - /// - /// ExecuteRoutes method - /// - public void ExecuteRoutes() - { - foreach (var route in Routes) - { - Debug.LogMessage(LogEventLevel.Verbose, "ExecuteRoutes: {0}", null, route.ToString()); - - if (route.SwitchingDevice is IRoutingSinkWithSwitching sink) - { - sink.ExecuteSwitch(route.InputPort.Selector); - continue; - } - - if (route.SwitchingDevice is IRouting switchingDevice) - { - switchingDevice.ExecuteSwitch(route.InputPort.Selector, route.OutputPort.Selector, SignalType); - - route.OutputPort.InUseTracker.AddUser(Destination, "destination-" + SignalType); - - Debug.LogMessage(LogEventLevel.Verbose, "Output port {0} routing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); - } - } - } - - /// - /// ReleaseRoutes method - /// - public void ReleaseRoutes() - { - foreach (var route in Routes) - { - if (route.SwitchingDevice is IRouting) - { - // Pull the route from the port. Whatever is watching the output's in use tracker is - // responsible for responding appropriately. - route.OutputPort.InUseTracker.RemoveUser(Destination, "destination-" + SignalType); - Debug.LogMessage(LogEventLevel.Verbose, "Port {0} releasing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); - } - } - } - - /// - /// ToString method - /// - /// - public override string ToString() - { - var routesText = Routes.Select(r => r.ToString()).ToArray(); - return string.Format("Route table from {0} to {1}:\r{2}", Source.Key, Destination.Key, string.Join("\r", routesText)); - } - }*/ } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs b/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs index f389f719..5d8147cb 100644 --- a/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs +++ b/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs @@ -1,7 +1,7 @@ -using PepperDash.Core; -using Serilog.Events; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using PepperDash.Core; +using Serilog.Events; namespace PepperDash.Essentials.Core @@ -11,6 +11,9 @@ namespace PepperDash.Essentials.Core /// public class RouteDescriptorCollection { + /// + /// Gets the default collection of RouteDescriptors. + /// public static RouteDescriptorCollection DefaultCollection { get @@ -24,6 +27,11 @@ namespace PepperDash.Essentials.Core private readonly List RouteDescriptors = new List(); + /// + /// Gets an enumerable collection of all RouteDescriptors in this collection. + /// + public IEnumerable Descriptors => RouteDescriptors.AsReadOnly(); + /// /// Adds a RouteDescriptor to the list. If an existing RouteDescriptor for the /// destination exists already, it will not be added - in order to preserve @@ -37,13 +45,29 @@ namespace PepperDash.Essentials.Core return; } - if (RouteDescriptors.Any(t => t.Destination == descriptor.Destination) - && RouteDescriptors.Any(t => t.Destination == descriptor.Destination && t.InputPort != null && descriptor.InputPort != null && t.InputPort.Key == descriptor.InputPort.Key)) + // Check if a route already exists with the same source, destination, input port, AND signal type + var existingRoute = RouteDescriptors.FirstOrDefault(t => + t.Source == descriptor.Source && + t.Destination == descriptor.Destination && + t.SignalType == descriptor.SignalType && + ((t.InputPort == null && descriptor.InputPort == null) || + (t.InputPort != null && descriptor.InputPort != null && t.InputPort.Key == descriptor.InputPort.Key))); + + if (existingRoute != null) { - Debug.LogMessage(LogEventLevel.Debug, descriptor.Destination, - "Route to [{0}] already exists in global routes table", descriptor?.Source?.Key); + Debug.LogMessage(LogEventLevel.Information, descriptor.Destination, + "Route from {0} to {1}:{2} ({3}) already exists in this collection", + descriptor?.Source?.Key, + descriptor?.Destination?.Key, + descriptor?.InputPort?.Key ?? "auto", + descriptor?.SignalType); return; } + Debug.LogMessage(LogEventLevel.Verbose, "Adding route descriptor: {0} -> {1}:{2} ({3})", + descriptor?.Source?.Key, + descriptor?.Destination?.Key, + descriptor?.InputPort?.Key ?? "auto", + descriptor?.SignalType); RouteDescriptors.Add(descriptor); } @@ -57,6 +81,12 @@ namespace PepperDash.Essentials.Core return RouteDescriptors.FirstOrDefault(rd => rd.Destination == destination); } + /// + /// Gets the route descriptor for a specific destination and input port + /// + /// The destination device + /// The input port key + /// The matching RouteDescriptor or null if not found public RouteDescriptor GetRouteDescriptorForDestinationAndInputPort(IRoutingInputs destination, string inputPortKey) { Debug.LogMessage(LogEventLevel.Information, "Getting route descriptor for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); @@ -73,7 +103,7 @@ namespace PepperDash.Essentials.Core { Debug.LogMessage(LogEventLevel.Information, "Removing route descriptor for '{destination}':'{inputPortKey}'", destination.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); - var descr = string.IsNullOrEmpty(inputPortKey) + var descr = string.IsNullOrEmpty(inputPortKey) ? GetRouteDescriptorForDestination(destination) : GetRouteDescriptorForDestinationAndInputPort(destination, inputPortKey); if (descr != null) @@ -84,70 +114,4 @@ namespace PepperDash.Essentials.Core return descr; } } - - /*/// - /// A collection of RouteDescriptors - typically the static DefaultCollection is used - /// - /// - /// Represents a RouteDescriptorCollection - /// - public class RouteDescriptorCollection - { - public static RouteDescriptorCollection DefaultCollection - { - get - { - if (_DefaultCollection == null) - _DefaultCollection = new RouteDescriptorCollection(); - return _DefaultCollection; - } - } - private static RouteDescriptorCollection _DefaultCollection; - - private readonly List RouteDescriptors = new List(); - - /// - /// Adds a RouteDescriptor to the list. If an existing RouteDescriptor for the - /// destination exists already, it will not be added - in order to preserve - /// proper route releasing. - /// - /// - /// - /// AddRouteDescriptor method - /// - public void AddRouteDescriptor(RouteDescriptor descriptor) - { - if (RouteDescriptors.Any(t => t.Destination == descriptor.Destination)) - { - Debug.LogMessage(LogEventLevel.Debug, descriptor.Destination, - "Route to [{0}] already exists in global routes table", descriptor.Source.Key); - return; - } - RouteDescriptors.Add(descriptor); - } - - /// - /// Gets the RouteDescriptor for a destination - /// - /// null if no RouteDescriptor for a destination exists - /// - /// GetRouteDescriptorForDestination method - /// - public RouteDescriptor GetRouteDescriptorForDestination(IRoutingInputs destination) - { - return RouteDescriptors.FirstOrDefault(rd => rd.Destination == destination); - } - - /// - /// Returns the RouteDescriptor for a given destination AND removes it from collection. - /// Returns null if no route with the provided destination exists. - /// - public RouteDescriptor RemoveRouteDescriptor(IRoutingInputs destination) - { - var descr = GetRouteDescriptorForDestination(destination); - if (descr != null) - RouteDescriptors.Remove(descr); - return descr; - } - }*/ } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs b/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs index 12aebdcf..227e6d5b 100644 --- a/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs +++ b/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs @@ -4,96 +4,51 @@ /// Represents a RouteSwitchDescriptor /// public class RouteSwitchDescriptor - { - /// - /// Gets or sets the SwitchingDevice - /// - public IRoutingInputs SwitchingDevice { get { return InputPort?.ParentDevice; } } - /// - /// The output port being switched from (relevant for matrix switchers). Null for sink devices. - /// - public RoutingOutputPort OutputPort { get; set; } - /// - /// The input port being switched to. - /// - public RoutingInputPort InputPort { get; set; } - - /// - /// Initializes a new instance of the class for sink devices (no output port). - /// - /// The input port being switched to. - public RouteSwitchDescriptor(RoutingInputPort inputPort) - { - InputPort = inputPort; - } - - /// - /// Initializes a new instance of the class for matrix switchers. - /// - /// The output port being switched from. - /// The input port being switched to. - public RouteSwitchDescriptor(RoutingOutputPort outputPort, RoutingInputPort inputPort) - { - InputPort = inputPort; - OutputPort = outputPort; - } - - /// - /// Returns a string representation of the route switch descriptor. - /// - /// A string describing the switch operation. - /// - public override string ToString() - { - if (SwitchingDevice is IRouting) - return $"{(SwitchingDevice != null ? SwitchingDevice.Key : "No Device")} switches output {(OutputPort != null ? OutputPort.Key : "No output port")} to input {(InputPort != null ? InputPort.Key : "No input port")}"; - else - return $"{(SwitchingDevice != null ? SwitchingDevice.Key : "No Device")} switches to input {(InputPort != null ? InputPort.Key : "No input port")}"; - } - } - - /*/// - /// Represents an individual link for a route - /// - /// - /// Represents a RouteSwitchDescriptor - /// - public class RouteSwitchDescriptor { /// /// Gets or sets the SwitchingDevice /// - public IRoutingInputs SwitchingDevice { get { return InputPort.ParentDevice; } } + public IRoutingInputs SwitchingDevice { get { return InputPort?.ParentDevice; } } /// - /// Gets or sets the OutputPort + /// The output port being switched from (relevant for matrix switchers). Null for sink devices. /// - public RoutingOutputPort OutputPort { get; set; } + public RoutingOutputPort OutputPort { get; set; } /// - /// Gets or sets the InputPort + /// The input port being switched to. /// - public RoutingInputPort InputPort { get; set; } + public RoutingInputPort InputPort { get; set; } - public RouteSwitchDescriptor(RoutingInputPort inputPort) + /// + /// Initializes a new instance of the class for sink devices (no output port). + /// + /// The input port being switched to. + public RouteSwitchDescriptor(RoutingInputPort inputPort) { InputPort = inputPort; } - public RouteSwitchDescriptor(RoutingOutputPort outputPort, RoutingInputPort inputPort) + /// + /// Initializes a new instance of the class for matrix switchers. + /// + /// The output port being switched from. + /// The input port being switched to. + public RouteSwitchDescriptor(RoutingOutputPort outputPort, RoutingInputPort inputPort) { InputPort = inputPort; OutputPort = outputPort; } /// - /// ToString method + /// Returns a string representation of the route switch descriptor. /// + /// A string describing the switch operation. /// public override string ToString() { if (SwitchingDevice is IRouting) - return string.Format("{0} switches output '{1}' to input '{2}'", SwitchingDevice.Key, OutputPort.Selector, InputPort.Selector); + return $"{(SwitchingDevice != null ? SwitchingDevice.Key : "No Device")} switches output {(OutputPort != null ? OutputPort.Key : "No output port")} to input {(InputPort != null ? InputPort.Key : "No input port")}"; else - return string.Format("{0} switches to input '{1}'", SwitchingDevice.Key, InputPort.Selector); + return $"{(SwitchingDevice != null ? SwitchingDevice.Key : "No Device")} switches to input {(InputPort != null ? InputPort.Key : "No input port")}"; } - }*/ + } } \ No newline at end of file diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index ef19c955..5fb7771e 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO.Compression; using System.Linq; using System.Reflection; @@ -92,12 +93,16 @@ namespace PepperDash.Essentials CrestronConsole.AddNewConsoleCommand(s => Debug.LogMessage(LogEventLevel.Information, "CONSOLE MESSAGE: {0}", s), "appdebugmessage", "Writes message to log", ConsoleAccessLevelEnum.AccessOperator); - CrestronConsole.AddNewConsoleCommand(s => - { - foreach (var tl in TieLineCollection.Default) - CrestronConsole.ConsoleCommandResponse(" {0}{1}", tl, CrestronEnvironment.NewLine); - }, - "listtielines", "Prints out all tie lines", ConsoleAccessLevelEnum.AccessOperator); + CrestronConsole.AddNewConsoleCommand(ListTieLines, + "listtielines", "Prints out all tie lines. Usage: listtielines [signaltype]", ConsoleAccessLevelEnum.AccessOperator); + + CrestronConsole.AddNewConsoleCommand(VisualizeRoutes, "visualizeroutes", + "Visualizes routes by signal type", + ConsoleAccessLevelEnum.AccessOperator); + + CrestronConsole.AddNewConsoleCommand(VisualizeCurrentRoutes, "visualizecurrentroutes", + "Visualizes current active routes from DefaultCollection", + ConsoleAccessLevelEnum.AccessOperator); CrestronConsole.AddNewConsoleCommand(s => { @@ -443,6 +448,282 @@ namespace PepperDash.Essentials Debug.LogMessage(LogEventLevel.Information, "All Tie Lines Loaded."); + Extensions.MapDestinationsToSources(); + + Debug.LogMessage(LogEventLevel.Information, "All Routes Mapped."); + } + + + + /// + /// Visualizes routes in a tree format for better understanding of signal paths + /// + private void ListTieLines(string args) + { + try + { + if (args.Contains("?")) + { + CrestronConsole.ConsoleCommandResponse("Usage: listtielines [signaltype]\r\n"); + CrestronConsole.ConsoleCommandResponse("Signal types: Audio, Video, SecondaryAudio, AudioVideo, UsbInput, UsbOutput\r\n"); + return; + } + + eRoutingSignalType? signalTypeFilter = null; + if (!string.IsNullOrEmpty(args)) + { + eRoutingSignalType parsedType; + if (Enum.TryParse(args.Trim(), true, out parsedType)) + { + signalTypeFilter = parsedType; + } + else + { + CrestronConsole.ConsoleCommandResponse("Invalid signal type: {0}\r\n", args.Trim()); + CrestronConsole.ConsoleCommandResponse("Valid types: Audio, Video, SecondaryAudio, AudioVideo, UsbInput, UsbOutput\r\n"); + return; + } + } + + var tielines = signalTypeFilter.HasValue + ? TieLineCollection.Default.Where(tl => tl.Type.HasFlag(signalTypeFilter.Value)) + : TieLineCollection.Default; + + var count = 0; + foreach (var tl in tielines) + { + CrestronConsole.ConsoleCommandResponse(" {0}{1}", tl, CrestronEnvironment.NewLine); + count++; + } + + CrestronConsole.ConsoleCommandResponse("\r\nTotal: {0} tieline{1}{2}", count, count == 1 ? "" : "s", CrestronEnvironment.NewLine); + } + catch (Exception ex) + { + CrestronConsole.ConsoleCommandResponse("Error listing tielines: {0}\r\n", ex.Message); + } + } + + private void VisualizeRoutes(string args) + { + try + { + if (args.Contains("?")) + { + CrestronConsole.ConsoleCommandResponse("Usage: visualizeroutes [signaltype] [-s source] [-d destination]\r\n"); + CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n"); + CrestronConsole.ConsoleCommandResponse(" -s: Filter by source key (partial match)\r\n"); + CrestronConsole.ConsoleCommandResponse(" -d: Filter by destination key (partial match)\r\n"); + return; + } + + ParseRouteFilters(args, out eRoutingSignalType? signalTypeFilter, out string sourceFilter, out string destFilter); + + CrestronConsole.ConsoleCommandResponse("\r\n+===========================================================================+\r\n"); + CrestronConsole.ConsoleCommandResponse("| ROUTE VISUALIZATION |\r\n"); + CrestronConsole.ConsoleCommandResponse("+===========================================================================+\r\n\r\n"); + + foreach (var descriptorCollection in Extensions.RouteDescriptors.Where(kv => kv.Value.Descriptors.Count() > 0)) + { + // Filter by signal type if specified + if (signalTypeFilter.HasValue && descriptorCollection.Key != signalTypeFilter.Value) + continue; + + CrestronConsole.ConsoleCommandResponse("\r\n+--- Signal Type: {0} ({1} routes) ---\r\n", + descriptorCollection.Key, + descriptorCollection.Value.Descriptors.Count()); + + foreach (var descriptor in descriptorCollection.Value.Descriptors) + { + // Filter by source/dest if specified + if (sourceFilter != null && !descriptor.Source.Key.ToLower().Contains(sourceFilter)) + continue; + if (destFilter != null && !descriptor.Destination.Key.ToLower().Contains(destFilter)) + continue; + + VisualizeRouteDescriptor(descriptor); + } + } + + CrestronConsole.ConsoleCommandResponse("\r\n"); + } + catch (Exception ex) + { + CrestronConsole.ConsoleCommandResponse("Error visualizing routes: {0}\r\n", ex.Message); + } + } + + private void VisualizeCurrentRoutes(string args) + { + try + { + if (args.Contains("?")) + { + CrestronConsole.ConsoleCommandResponse("Usage: visualizecurrentroutes [signaltype] [-s source] [-d destination]\r\n"); + CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n"); + CrestronConsole.ConsoleCommandResponse(" -s: Filter by source key (partial match)\r\n"); + CrestronConsole.ConsoleCommandResponse(" -d: Filter by destination key (partial match)\r\n"); + return; + } + + ParseRouteFilters(args, out eRoutingSignalType? signalTypeFilter, out string sourceFilter, out string destFilter); + + CrestronConsole.ConsoleCommandResponse("\r\n+===========================================================================+\r\n"); + CrestronConsole.ConsoleCommandResponse("| CURRENT ROUTES VISUALIZATION |\r\n"); + CrestronConsole.ConsoleCommandResponse("+===========================================================================+\r\n\r\n"); + + var hasRoutes = false; + + // Get all descriptors from DefaultCollection + var allDescriptors = RouteDescriptorCollection.DefaultCollection.Descriptors; + + // Group by signal type + var groupedByType = allDescriptors.GroupBy(d => d.SignalType); + + foreach (var group in groupedByType) + { + var signalType = group.Key; + + // Filter by signal type if specified + if (signalTypeFilter.HasValue && signalType != signalTypeFilter.Value) + continue; + + var filteredDescriptors = group.Where(d => + { + if (sourceFilter != null && !d.Source.Key.ToLower().Contains(sourceFilter)) + return false; + if (destFilter != null && !d.Destination.Key.ToLower().Contains(destFilter)) + return false; + return true; + }).ToList(); + + if (filteredDescriptors.Count == 0) + continue; + + hasRoutes = true; + CrestronConsole.ConsoleCommandResponse("\r\n+--- Signal Type: {0} ({1} routes) ---\r\n", + signalType, + filteredDescriptors.Count); + + foreach (var descriptor in filteredDescriptors) + { + VisualizeRouteDescriptor(descriptor); + } + } + + if (!hasRoutes) + { + CrestronConsole.ConsoleCommandResponse("\r\nNo active routes found in current state.\r\n"); + } + + CrestronConsole.ConsoleCommandResponse("\r\n"); + } + catch (Exception ex) + { + CrestronConsole.ConsoleCommandResponse("Error visualizing current state: {0}\r\n", ex.Message); + } + } + + /// + /// Parses route filter arguments from command line + /// + /// Command line arguments + /// Parsed signal type filter (if any) + /// Parsed source filter (if any) + /// Parsed destination filter (if any) + private void ParseRouteFilters(string args, out eRoutingSignalType? signalTypeFilter, out string sourceFilter, out string destFilter) + { + signalTypeFilter = null; + sourceFilter = null; + destFilter = null; + + if (string.IsNullOrEmpty(args)) + return; + + var parts = args.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < parts.Length; i++) + { + var part = parts[i]; + + // Check for flags + if (part == "-s" && i + 1 < parts.Length) + { + sourceFilter = parts[++i].ToLower(); + } + else if (part == "-d" && i + 1 < parts.Length) + { + destFilter = parts[++i].ToLower(); + } + // Try to parse as signal type if not a flag and no signal type set yet + else if (!part.StartsWith("-") && !signalTypeFilter.HasValue) + { + if (Enum.TryParse(part, true, out eRoutingSignalType parsedType)) + { + signalTypeFilter = parsedType; + } + } + } + } + + /// + /// Visualizes a single route descriptor in a tree format + /// + private void VisualizeRouteDescriptor(RouteDescriptor descriptor) + { + CrestronConsole.ConsoleCommandResponse("|\r\n"); + CrestronConsole.ConsoleCommandResponse("|-- {0} --> {1}\r\n", + descriptor.Source.Key, + descriptor.Destination.Key); + + if (descriptor.Routes == null || descriptor.Routes.Count == 0) + { + CrestronConsole.ConsoleCommandResponse("| +-- (No switching steps)\r\n"); + return; + } + + for (int i = 0; i < descriptor.Routes.Count; i++) + { + var route = descriptor.Routes[i]; + var isLast = i == descriptor.Routes.Count - 1; + var prefix = isLast ? "+" : "|"; + var continuation = isLast ? " " : "|"; + + if (route.SwitchingDevice != null) + { + CrestronConsole.ConsoleCommandResponse("| {0}-- [{1}] {2}\r\n", + prefix, + route.SwitchingDevice.Key, + GetSwitchDescription(route)); + + // Add visual connection line for non-last items + if (!isLast) + CrestronConsole.ConsoleCommandResponse("| {0} |\r\n", continuation); + } + else + { + CrestronConsole.ConsoleCommandResponse("| {0}-- {1}\r\n", prefix, route.ToString()); + } + } + } + + /// + /// Gets a readable description of the switching operation + /// + private string GetSwitchDescription(RouteSwitchDescriptor route) + { + if (route.OutputPort != null && route.InputPort != null) + { + return string.Format("{0} -> {1}", route.OutputPort.Key, route.InputPort.Key); + } + else if (route.InputPort != null) + { + return string.Format("-> {0}", route.InputPort.Key); + } + else + { + return "(passthrough)"; + } } /// From 9c9a643b6a57eda1e9eb40c1e765986fcda4c5d0 Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Wed, 14 Jan 2026 10:22:35 -0600 Subject: [PATCH 04/46] feat: add CWS endpoint to get routing devices & tielines together --- .../Web/EssentialsWebApi.cs | 218 +++++++++--------- .../GetRoutingDevicesAndTieLinesHandler.cs | 178 ++++++++++++++ 2 files changed, 290 insertions(+), 106 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index ffbe5509..c498fedf 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -10,71 +10,71 @@ using Serilog.Events; namespace PepperDash.Essentials.Core.Web { - /// - /// Represents a EssentialsWebApi - /// - public class EssentialsWebApi : EssentialsDevice - { - private readonly WebApiServer _server; + /// + /// Represents a EssentialsWebApi + /// + public class EssentialsWebApi : EssentialsDevice + { + private readonly WebApiServer _server; - /// - /// http(s)://{ipaddress}/cws/{basePath} - /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} - /// - private readonly string _defaultBasePath = CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance - ? string.Format("/app{0:00}/api", InitialParametersClass.ApplicationNumber) - : "/api"; + /// + /// http(s)://{ipaddress}/cws/{basePath} + /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} + /// + private readonly string _defaultBasePath = CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance + ? string.Format("/app{0:00}/api", InitialParametersClass.ApplicationNumber) + : "/api"; - private const int DebugTrace = 0; - private const int DebugInfo = 1; - private const int DebugVerbose = 2; + private const int DebugTrace = 0; + private const int DebugInfo = 1; + private const int DebugVerbose = 2; - /// - /// Gets or sets the BasePath - /// - public string BasePath { get; private set; } + /// + /// Gets or sets the BasePath + /// + public string BasePath { get; private set; } - /// - /// Tracks if CWS is registered - /// - public bool IsRegistered - { - get { return _server.IsRegistered; } - } + /// + /// Tracks if CWS is registered + /// + public bool IsRegistered + { + get { return _server.IsRegistered; } + } - /// - /// Constructor - /// - /// - /// - public EssentialsWebApi(string key, string name) - : this(key, name, null) - { - } + /// + /// Constructor + /// + /// + /// + public EssentialsWebApi(string key, string name) + : this(key, name, null) + { + } - /// - /// Constructor - /// - /// - /// - /// - public EssentialsWebApi(string key, string name, EssentialsWebApiPropertiesConfig config) - : base(key, name) - { - Key = key; + /// + /// Constructor + /// + /// + /// + /// + public EssentialsWebApi(string key, string name, EssentialsWebApiPropertiesConfig config) + : base(key, name) + { + Key = key; - if (config == null) - BasePath = _defaultBasePath; - else - BasePath = string.IsNullOrEmpty(config.BasePath) ? _defaultBasePath : config.BasePath; + if (config == null) + BasePath = _defaultBasePath; + else + BasePath = string.IsNullOrEmpty(config.BasePath) ? _defaultBasePath : config.BasePath; - _server = new WebApiServer(Key, Name, BasePath); + _server = new WebApiServer(Key, Name, BasePath); - SetupRoutes(); - } + SetupRoutes(); + } - private void SetupRoutes() - { + private void SetupRoutes() + { var routes = new List { new HttpCwsRoute("versions") @@ -177,6 +177,11 @@ namespace PepperDash.Essentials.Core.Web Name = "Get Routing Ports for a device", RouteHandler = new GetRoutingPortsHandler() }, + new HttpCwsRoute("routingDevicesAndTieLines") + { + Name = "Get Routing Devices and TieLines", + RouteHandler = new GetRoutingDevicesAndTieLinesHandler() + }, }; AddRoute(routes); @@ -211,78 +216,79 @@ namespace PepperDash.Essentials.Core.Web /// /// public override void Initialize() - { - AddRoute(new HttpCwsRoute("apiPaths") { + { + AddRoute(new HttpCwsRoute("apiPaths") + { Name = "GetPaths", RouteHandler = new GetRoutesHandler(_server.GetRouteCollection(), BasePath) }); // If running on an appliance if (CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance) - { - /* + { + /* WEBSERVER [ON | OFF | TIMEOUT | MAXSESSIONSPERUSER ] */ - var response = string.Empty; - CrestronConsole.SendControlSystemCommand("webserver", ref response); - if (response.Contains("OFF")) return; + var response = string.Empty; + CrestronConsole.SendControlSystemCommand("webserver", ref response); + if (response.Contains("OFF")) return; - var is4Series = eCrestronSeries.Series4 == (Global.ProcessorSeries & eCrestronSeries.Series4); - Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series"); + var is4Series = eCrestronSeries.Series4 == (Global.ProcessorSeries & eCrestronSeries.Series4); + Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series"); - _server.Start(); + _server.Start(); - GetPaths(); + GetPaths(); - return; - } + return; + } - // Automatically start CWS when running on a server (Linux OS, Virtual Control) - Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server"); + // Automatically start CWS when running on a server (Linux OS, Virtual Control) + Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server"); - _server.Start(); + _server.Start(); - GetPaths(); - } + GetPaths(); + } - /// - /// Print the available pahts - /// - /// - /// http(s)://{ipaddress}/cws/{basePath} - /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} - /// - /// - /// GetPaths method - /// - public void GetPaths() - { - Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); + /// + /// Print the available pahts + /// + /// + /// http(s)://{ipaddress}/cws/{basePath} + /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} + /// + /// + /// GetPaths method + /// + public void GetPaths() + { + Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); - var currentIp = CrestronEthernetHelper.GetEthernetParameter( - CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); - - var hostname = CrestronEthernetHelper.GetEthernetParameter( + var currentIp = CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); + + var hostname = CrestronEthernetHelper.GetEthernetParameter( CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0); var path = CrestronEnvironment.DevicePlatform == eDevicePlatform.Server ? $"https://{hostname}/VirtualControl/Rooms/{InitialParametersClass.RoomId}/cws{BasePath}" : $"https://{currentIp}/cws{BasePath}"; - - Debug.LogMessage(LogEventLevel.Information, this, "Server:{path:l}", path); - var routeCollection = _server.GetRouteCollection(); - if (routeCollection == null) - { - Debug.LogMessage(LogEventLevel.Information, this, "Server route collection is null"); - return; - } - Debug.LogMessage(LogEventLevel.Information, this, "Configured Routes:"); - foreach (var route in routeCollection) - { - Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url); - } - Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); - } - } + Debug.LogMessage(LogEventLevel.Information, this, "Server:{path:l}", path); + + var routeCollection = _server.GetRouteCollection(); + if (routeCollection == null) + { + Debug.LogMessage(LogEventLevel.Information, this, "Server route collection is null"); + return; + } + Debug.LogMessage(LogEventLevel.Information, this, "Configured Routes:"); + foreach (var route in routeCollection) + { + Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url); + } + Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); + } + } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs new file mode 100644 index 00000000..0d65b0e3 --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs @@ -0,0 +1,178 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core; +using PepperDash.Core.Web.RequestHandlers; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Handles HTTP requests to retrieve routing devices and tielines information + /// + public class GetRoutingDevicesAndTieLinesHandler : WebApiBaseRequestHandler + { + public GetRoutingDevicesAndTieLinesHandler() : base(true) { } + + protected override void HandleGet(HttpCwsContext context) + { + var devices = new List(); + + // Get all devices from DeviceManager + foreach (var device in DeviceManager.AllDevices) + { + var deviceInfo = new RoutingDeviceInfo + { + Key = device.Key, + Name = (device as IKeyName)?.Name ?? device.Key + }; + + // Check if device implements IRoutingInputs + if (device is IRoutingInputs inputDevice) + { + deviceInfo.HasInputs = true; + deviceInfo.InputPorts = inputDevice.InputPorts.Select(p => new PortInfo + { + Key = p.Key, + SignalType = p.Type.ToString(), + ConnectionType = p.ConnectionType.ToString(), + IsInternal = p.IsInternal + }).ToList(); + } + + // Check if device implements IRoutingOutputs + if (device is IRoutingOutputs outputDevice) + { + deviceInfo.HasOutputs = true; + deviceInfo.OutputPorts = outputDevice.OutputPorts.Select(p => new PortInfo + { + Key = p.Key, + SignalType = p.Type.ToString(), + ConnectionType = p.ConnectionType.ToString(), + IsInternal = p.IsInternal + }).ToList(); + } + + // Check if device implements IRoutingInputsOutputs + if (device is IRoutingInputsOutputs) + { + deviceInfo.HasInputsAndOutputs = true; + } + + // Only include devices that have routing capabilities + if (deviceInfo.HasInputs || deviceInfo.HasOutputs) + { + devices.Add(deviceInfo); + } + } + + // Get all tielines + var tielines = TieLineCollection.Default.Select(tl => new TieLineInfo + { + SourceDeviceKey = tl.SourcePort.ParentDevice.Key, + SourcePortKey = tl.SourcePort.Key, + DestinationDeviceKey = tl.DestinationPort.ParentDevice.Key, + DestinationPortKey = tl.DestinationPort.Key, + SignalType = tl.Type.ToString(), + IsInternal = tl.IsInternal + }).ToList(); + + var response = new RoutingSystemInfo + { + Devices = devices, + TieLines = tielines + }; + + var jsonResponse = JsonConvert.SerializeObject(response, Formatting.Indented); + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = Encoding.UTF8; + context.Response.Write(jsonResponse, false); + context.Response.End(); + } + } + + /// + /// Represents the complete routing system information including devices and tielines + /// + public class RoutingSystemInfo + { + [JsonProperty("devices")] + public List Devices { get; set; } + + [JsonProperty("tieLines")] + public List TieLines { get; set; } + } + + /// + /// Represents a routing device with its ports information + /// + public class RoutingDeviceInfo + { + [JsonProperty("key")] + public string Key { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("hasInputs")] + public bool HasInputs { get; set; } + + [JsonProperty("hasOutputs")] + public bool HasOutputs { get; set; } + + [JsonProperty("hasInputsAndOutputs")] + public bool HasInputsAndOutputs { get; set; } + + [JsonProperty("inputPorts", NullValueHandling = NullValueHandling.Ignore)] + public List InputPorts { get; set; } + + [JsonProperty("outputPorts", NullValueHandling = NullValueHandling.Ignore)] + public List OutputPorts { get; set; } + } + + /// + /// Represents a routing port with its properties + /// + public class PortInfo + { + [JsonProperty("key")] + public string Key { get; set; } + + [JsonProperty("signalType")] + public string SignalType { get; set; } + + [JsonProperty("connectionType")] + public string ConnectionType { get; set; } + + [JsonProperty("isInternal")] + public bool IsInternal { get; set; } + } + + /// + /// Represents a tieline connection between two ports + /// + public class TieLineInfo + { + [JsonProperty("sourceDeviceKey")] + public string SourceDeviceKey { get; set; } + + [JsonProperty("sourcePortKey")] + public string SourcePortKey { get; set; } + + [JsonProperty("destinationDeviceKey")] + public string DestinationDeviceKey { get; set; } + + [JsonProperty("destinationPortKey")] + public string DestinationPortKey { get; set; } + + [JsonProperty("signalType")] + public string SignalType { get; set; } + + [JsonProperty("isInternal")] + public bool IsInternal { get; set; } + } +} From a82cf4f4494c387672aa04235bc40b9ced677ff4 Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Thu, 2 Apr 2026 15:21:59 -0500 Subject: [PATCH 05/46] docs: udpate routing documentation to match the algorithm --- .../Connection-Based-Routing.md | 152 +++++++++++++++++- 1 file changed, 144 insertions(+), 8 deletions(-) diff --git a/docs/docs/technical-docs/Connection-Based-Routing.md b/docs/docs/technical-docs/Connection-Based-Routing.md index 4ba902db..4263710c 100644 --- a/docs/docs/technical-docs/Connection-Based-Routing.md +++ b/docs/docs/technical-docs/Connection-Based-Routing.md @@ -2,13 +2,35 @@ ## TL;DR -Routing is defined by a connection graph or a wiring diagram. Routeable devices are sources, midpoints, or destinations. Devices are connected by tie lines. Tie lines represent the cables connecting devices, and are of type audio, video or both. Routes are made by telling a destination to get an audio/video/combined route from a source. +Routing is defined by a connection graph or a wiring diagram. Routeable devices are sources, midpoints, or destinations. Devices are connected by tie lines. Tie lines represent the cables connecting devices, and have specific signal types (audio, video, audioVideo, secondaryAudio, usbInput, usbOutput). Routes are made by telling a destination to get a route from a source for a specific signal type. Combined signal types (e.g., audioVideo) are automatically split into separate routing operations. ## Summary Essentials routing is described by defining a graph of connections between devices in a system, typically in configuration. The audio, video and combination connections are like a wiring diagram. This graph is a collection of devices and tie lines, each tie line connecting a source device, source output port, destination device and destination input port. Tie lines are logically represented as a collection. -When routes are to be executed, Essentials will use this connection graph to decide on routes from source to destination. A method call is made on a destination, which says “destination, find a way for source xyz to get to you.” An algorithm analyzes the tie lines, instantly walking backwards from the destination, down every connection until it finds a complete path from the source. If a connected path is found, the algorithm then walks forward through all midpoints to the destination, executing switches as required until the full route is complete. The developer or configurer only needs to say “destination, get source xyz” and Essentials figures out how, regardless of what devices lie in between. +When routes are to be executed, Essentials will use this connection graph to decide on routes from source to destination. A method call is made on a destination, which says "destination, find a way for source xyz to get to you." An algorithm analyzes the tie lines, instantly walking backwards from the destination, down every connection until it finds a complete path from the source. If a connected path is found, the algorithm then walks forward through all midpoints to the destination, executing switches as required until the full route is complete. The developer or configurer only needs to say "destination, get source xyz" and Essentials figures out how, regardless of what devices lie in between. + +### Signal Type Handling + +When a combined signal type like `audioVideo` is requested, Essentials automatically splits it into two separate routing operations—one for audio and one for video. Each signal type is routed independently through the system, ensuring that: +- Audio-only tie lines can be used for the audio portion +- Video-only tie lines can be used for the video portion +- AudioVideo tie lines can be used for both portions + +During path discovery, **only tie lines that support the requested signal type are considered**. For example, if a video route is requested, only tie lines with the video flag will be evaluated. This ensures signal compatibility throughout the entire routing chain. + +### Port-Specific Routing + +The routing system supports routing to and from specific ports on devices. You can specify: +- A specific input port on the destination device +- A specific output port on the source device +- Both specific ports for precise routing control + +When no specific ports are specified, the algorithm will automatically discover the appropriate ports based on available tie lines. + +### Request Queuing + +All routing requests are processed sequentially through a queue. For devices that implement warming/cooling behavior (e.g., projectors), route requests are automatically held when a device is cooling down and executed once the device is ready. This prevents routing errors and ensures proper device state management. ### Classes Referenced @@ -23,15 +45,15 @@ When routes are to be executed, Essentials will use this connection graph to dec The diagram below shows the connections in a simple presentation system, with a few variations in connection paths. Example routes will be described following the diagram. -Each visible line between ports on devices represents a tie line. A tie line connects an output port on one device to an input port on another device, for example: an HDMI port on a document camera to an HDMI input on a matrix switcher. A tie line may be audio, video or both. It is essentially a logical representation of a physical cable in a system. This diagram has 12 tie lines, and those tie lines are defined in the tieLines array in configuration. +Each visible line between ports on devices represents a tie line. A tie line connects an output port on one device to an input port on another device, for example: an HDMI port on a document camera to an HDMI input on a matrix switcher. A tie line has a signal type (audio, video, audioVideo, secondaryAudio, usbInput, or usbOutput) that determines what signals can travel through it. It is essentially a logical representation of a physical cable in a system. This diagram has 12 tie lines, and those tie lines are defined in the tieLines array in configuration. ![Routing system diagram](~/docs/images/routing-system-diagram.png) Let’s go through some examples of routing, using pseudo-code: 1. Method call: “Projector 1, show Doc cam.” Routing will walk backwards through DM-RMC-3 and DM-8x8 iterating through all “wired up” ports until it finds a path back to the Doc cam. Routing will then step back through all devices in the discovered chain, switching routes on those that are switchable: Doc cam: no switching; DM 8x8: route input 3 to output 3; DM-RMC-3: no switching; Projector 1: Select input HDMI In. Route is complete. -2. Method call: “Projector 2, show Laptop, video-only.” Routing will walk backwards through DM-RMC-4, DM 8x8, DM-TX-1, iterating through all connected ports until it finds a connection to the laptop. Routing then steps back through all devices, switching video where it can: Laptop: No switching; DM-TX-1: Select HDMI in; DM 8x8: Route input 5 to output 4; DM-RMC-4: No switching; Projector 2: Select HDMI input. Route is complete. -3. Method call: “Amplifier, connect Laptop audio.” Again walking backwards to Laptop, as in #2 above. Switching will take place on DM-TX-1, DM 8x8, audio-only. +2. Method call: "Projector 2, show Laptop, video-only." Routing will walk backwards through DM-RMC-4, DM 8x8, DM-TX-1, iterating through all connected ports until it finds a connection to the laptop. During this search, only tie lines that support video signals are considered. Routing then steps back through all devices, switching video where it can: Laptop: No switching; DM-TX-1: Select HDMI in; DM 8x8: Route input 5 to output 4; DM-RMC-4: No switching; Projector 2: Select HDMI input. Route is complete. +3. Method call: "Amplifier, connect Laptop audio." Again walking backwards to Laptop, as in #2 above, but this time only tie lines supporting audio signals are evaluated. Switching will take place on DM-TX-1, DM 8x8, audio-only. 4. Very simple call: “Lobby display, show signage controller.” Routing will walk back on HDMI input 1 and immediately find the signage controller. It then does a switch to HDMI 1 on the display. All four of the above could be logically combined in a series of calls to define a possible “scene” in a room: Put Document camera on Projector 1, put Laptop on Projector 2 and the audio, put Signage on the Lobby display. They key takeaway is that the developer doesn’t need to define what is involved in making a certain route. The person configuring the system defines how it’s wired up, and the code only needs to tell a given destination to get a source, likely through configuration as well. @@ -40,6 +62,37 @@ All of the above routes can be defined in source list routing tables, covered el --- +## Routing Algorithm Details + +### Combined Signal Type Splitting + +When an `audioVideo` route is requested, the routing system automatically splits it into two independent routing operations: + +1. **Audio Route**: Finds the best path for audio signals from source to destination +2. **Video Route**: Finds the best path for video signals from source to destination + +Each route can take a different physical path through the system. For example: +- Video might travel: Laptop → DM-TX-1 → DM Matrix → Display +- Audio might travel: Laptop → DM-TX-1 → DM Matrix → Audio Processor → Amplifier + +Both routes are discovered, stored, and executed independently. This allows for flexible system designs where audio and video follow different paths. + +The same splitting behavior occurs for `Video + SecondaryAudio` requests, where video and secondary audio are routed as separate operations. + +### Signal Type Filtering + +At each step of the route discovery process, the algorithm filters tie lines based on the requested signal type: + +- **Video request**: Only considers tie lines with the `video` flag set +- **Audio request**: Only considers tie lines with the `audio` flag set +- **AudioVideo request**: Routes audio and video separately, each following their respective filtering rules + +If no tie line exists with the required signal type at any point in the chain, that path is rejected and the algorithm continues searching for an alternative route. If no valid path is found, the route request fails and no switching occurs. + +This filtering ensures that incompatible signal types never interfere with routing decisions. For example, an audio-only cable will never be selected when routing video, preventing misconfiguration errors. + +--- + ### Definitions #### Ports @@ -64,18 +117,101 @@ A sink is a device at the end of a full signal path. For example, a display, amp #### Tie-line -A tie-line is a logical representation of a physical cable connection between two devices. It has five properties that define how the tie-line connects two devices. A configuration snippet for a single tie line connecting HDMI output 1 on a Cisco RoomKit to HDMI input 1 on a display, carrying both audio and video, is shown below. +A tie-line is a logical representation of a physical cable connection between two devices. It has five properties that define how the tie-line connects two devices. + +##### How Tie Line Types Are Determined + +The effective type of a tie line is determined by one of two methods: + +1. **Automatic (Recommended)**: When no `type` property is specified in configuration, the tie line's type is automatically calculated as the **intersection** of signal types supported by both the source and destination ports. This ensures only compatible signals are considered for routing. + + Example: If a source port supports `AudioVideo` and the destination port supports `Audio`, the tie line will have type `Audio` (the only common type). + +2. **Manual Override**: When the `type` property is explicitly set, it overrides the automatic calculation. This is useful when the physical cable supports fewer signal types than both ports are capable of. + + Example: Both ports support `AudioVideo`, but the cable only carries audio, so you set `"type": "audio"`. + +##### Validation + +At startup, tie line configurations are validated to ensure: +- Both ports exist on their respective devices +- The source and destination ports have at least one common signal type +- If a `type` override is specified, both ports must support that signal type + +Invalid tie lines will fail to build with descriptive error messages, preventing runtime routing issues. + +##### Signal Types + +Tie lines support the following signal types: + +- `audio` - Audio-only signals +- `video` - Video-only signals +- `audioVideo` - Combined audio and video (automatically split during routing) +- `secondaryAudio` - Secondary audio channel (e.g., program audio separate from microphone audio) +- `usbInput` - USB input signals +- `usbOutput` - USB output signals + +The `type` property determines which signals can travel through the tie line. During route discovery, only tie lines matching the requested signal type will be considered as valid paths. + +**Note**: In most cases, you should omit the `type` property and let the system automatically calculate it from the port capabilities. Only use it when you need to restrict the tie line to fewer signal types than the ports support or when needed for clarity. + +##### Configuration Examples + +**Example 1: Automatic type calculation (recommended)** + +Connecting an HDMI cable between devices that both support audio and video. The `type` property is omitted, so the tie line will automatically support `AudioVideo`: ```json { "sourceKey": "ciscoSparkPlusCodec-1", "sourcePort": "HdmiOut1", "destinationKey": "display-1", - "destinationPort": "HdmiIn1", - "type": "audioVideo" + "destinationPort": "HdmiIn1" } ``` +**Example 2: Type override for cable limitations** + +Both devices support `AudioVideo`, but the physical cable only carries audio. The `type` property restricts routing to audio only: + +```json +{ + "sourceKey": "dmSwitcher-1", + "sourcePort": "audioVideoOut1", + "destinationKey": "amplifier-1", + "destinationPort": "audioVideoIn1", + "type": "audio" +} +``` + +**Example 3: Mismatched port types (automatically handled)** + +Source only supports audio, destination supports both. No `type` needed—the tie line will automatically be `Audio`: + +```json +{ + "sourceKey": "audioProcessor-1", + "sourcePort": "audioOut1", + "destinationKey": "dmSwitcher-1", + "destinationPort": "audioVideoIn1" +} +``` + +**Invalid Example: Incompatible types** + +This configuration will **fail validation** at startup because the ports have no common signal types: + +```json +{ + "sourceKey": "audioProcessor-1", + "sourcePort": "audioOut1", + "destinationKey": "display-1", + "destinationPort": "hdmiIn1", + "type": "video" +} +``` +Error: `"Override type 'Video' is not supported by source port 'audioOut1' (type: Audio)"` + ### Interfaces Todo: Define Interfaces IRouting, IRoutingOutputs, IRoutingInputs From c20d49f430b3b4ea5f83b8f472b3b832c382b7b0 Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Thu, 2 Apr 2026 15:22:17 -0500 Subject: [PATCH 06/46] fix: restrict tieline creation when signal types are invalid --- .../Routing/TieLine.cs | 24 +++++++-------- .../Routing/TieLineConfig.cs | 30 ++++++++++++++++++- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/TieLine.cs b/src/PepperDash.Essentials.Core/Routing/TieLine.cs index 42f024b4..87470eeb 100644 --- a/src/PepperDash.Essentials.Core/Routing/TieLine.cs +++ b/src/PepperDash.Essentials.Core/Routing/TieLine.cs @@ -1,6 +1,6 @@ -using Newtonsoft.Json; -using System; +using System; using System.Collections.Generic; +using Newtonsoft.Json; namespace PepperDash.Essentials.Core { @@ -21,24 +21,24 @@ namespace PepperDash.Essentials.Core //public int InUseCount { get { return DestinationUsingThis.Count; } } /// - /// Gets the type of this tie line. Will either be the type of the destination port - /// or the type of OverrideType when it is set. + /// Gets the type of this tie line. Returns the intersection of signal types supported by both + /// the source and destination ports (what signals can actually travel through this tie line), + /// or the OverrideType when it is set. /// public eRoutingSignalType Type { get { if (OverrideType.HasValue) return OverrideType.Value; - return DestinationPort.Type; + return SourcePort.Type & DestinationPort.Type; } } /// - /// Use this to override the Type property for the destination port. For example, - /// when the tie line is type AudioVideo, and the signal flow should be limited to - /// Audio-only or Video only, changing this type will alter the signal paths - /// available to the routing algorithm without affecting the actual Type - /// of the destination port. + /// Use this to override the Type property. For example, when both ports support AudioVideo + /// but the physical cable only carries Audio or Video, setting this will limit the signal + /// paths available to the routing algorithm without affecting the actual port types. + /// When set, this value is used instead of the calculated intersection of source and destination types. /// public eRoutingSignalType? OverrideType { get; set; } @@ -79,7 +79,7 @@ namespace PepperDash.Essentials.Core /// /// The source output port. /// The destination input port. - /// The signal type to limit the link to. Overrides DestinationPort.Type for routing calculations. + /// The signal type to limit the link to. Overrides the calculated intersection of port types for routing calculations. public TieLine(RoutingOutputPort sourcePort, RoutingInputPort destinationPort, eRoutingSignalType? overrideType) : this(sourcePort, destinationPort) { @@ -91,7 +91,7 @@ namespace PepperDash.Essentials.Core /// /// The source output port. /// The destination input port. - /// The signal type to limit the link to. Overrides DestinationPort.Type for routing calculations. + /// The signal type to limit the link to. Overrides the calculated intersection of port types for routing calculations. public TieLine(RoutingOutputPort sourcePort, RoutingInputPort destinationPort, eRoutingSignalType overrideType) : this(sourcePort, destinationPort) { diff --git a/src/PepperDash.Essentials.Core/Routing/TieLineConfig.cs b/src/PepperDash.Essentials.Core/Routing/TieLineConfig.cs index 156b39b1..606cb603 100644 --- a/src/PepperDash.Essentials.Core/Routing/TieLineConfig.cs +++ b/src/PepperDash.Essentials.Core/Routing/TieLineConfig.cs @@ -49,7 +49,9 @@ namespace PepperDash.Essentials.Core.Config public string DestinationPort { get; set; } /// - /// Optional override for the signal type of the tie line. If set, this overrides the destination port's type for routing calculations. + /// Optional override for the signal type of the tie line. If set, this overrides the calculated + /// intersection of source and destination port types for routing calculations. Useful when the + /// physical cable supports fewer signal types than both ports are capable of. /// [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] [JsonConverter(typeof(StringEnumConverter))] @@ -96,6 +98,32 @@ namespace PepperDash.Essentials.Core.Config return null; } + // Validate signal type compatibility + if (OverrideType.HasValue) + { + // When override type is specified, both ports must support it + if (!sourceOutputPort.Type.HasFlag(OverrideType.Value)) + { + LogError($"Override type '{OverrideType.Value}' is not supported by source port '{SourcePort}' (type: {sourceOutputPort.Type})"); + return null; + } + + if (!destinationInputPort.Type.HasFlag(OverrideType.Value)) + { + LogError($"Override type '{OverrideType.Value}' is not supported by destination port '{DestinationPort}' (type: {destinationInputPort.Type})"); + return null; + } + } + else + { + // Without override type, ports must have at least one common signal type flag + if ((sourceOutputPort.Type & destinationInputPort.Type) == 0) + { + LogError($"Incompatible signal types: source port '{SourcePort}' (type: {sourceOutputPort.Type}) has no common signal types with destination port '{DestinationPort}' (type: {destinationInputPort.Type})"); + return null; + } + } + return new TieLine(sourceOutputPort, destinationInputPort, OverrideType); } From 29d5804cb078da3507bdd0ee6b68b720dc19e832 Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Thu, 2 Apr 2026 17:31:02 -0500 Subject: [PATCH 07/46] docs: fix spelling error Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/docs/technical-docs/Connection-Based-Routing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/technical-docs/Connection-Based-Routing.md b/docs/docs/technical-docs/Connection-Based-Routing.md index 4263710c..07dc318b 100644 --- a/docs/docs/technical-docs/Connection-Based-Routing.md +++ b/docs/docs/technical-docs/Connection-Based-Routing.md @@ -2,7 +2,7 @@ ## TL;DR -Routing is defined by a connection graph or a wiring diagram. Routeable devices are sources, midpoints, or destinations. Devices are connected by tie lines. Tie lines represent the cables connecting devices, and have specific signal types (audio, video, audioVideo, secondaryAudio, usbInput, usbOutput). Routes are made by telling a destination to get a route from a source for a specific signal type. Combined signal types (e.g., audioVideo) are automatically split into separate routing operations. +Routing is defined by a connection graph or a wiring diagram. Routable devices are sources, midpoints, or destinations. Devices are connected by tie lines. Tie lines represent the cables connecting devices, and have specific signal types (audio, video, audioVideo, secondaryAudio, usbInput, usbOutput). Routes are made by telling a destination to get a route from a source for a specific signal type. Combined signal types (e.g., audioVideo) are automatically split into separate routing operations. ## Summary From e55ac38719347d16c0be1315d5d6e253e648f032 Mon Sep 17 00:00:00 2001 From: Erik Meyer Date: Mon, 13 Apr 2026 16:08:01 -0400 Subject: [PATCH 08/46] feat: update IHasWebView with webview state info --- .../DeviceTypeInterfaces/IHasWebView.cs | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWebView.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWebView.cs index 396229ce..b429a49d 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWebView.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWebView.cs @@ -3,9 +3,285 @@ using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; +using Newtonsoft.Json; namespace PepperDash.Essentials.Core.DeviceTypeInterfaces { + /// + /// Defines the display mode for a webview event, with expected values of "Fullscreen", "Modal", or "Unknown". + /// + public enum eWebViewEventMode + { + /// + /// The display mode for the webview event is unknown or not specified. This value can be used as a default or fallback when the display mode is not provided or cannot be parsed into a known value. + /// + Unknown, + + /// + /// The webview event should be displayed in fullscreen mode, covering the entire screen and typically used for immersive experiences or when maximum screen real estate is needed. When a webview event with this display mode is shown, it will typically trigger the WebViewStatusChanged event with a status of "Fullscreen", and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared". + /// + Fullscreen, + + /// + /// The webview event should be displayed in modal mode, which typically means it will be shown as a dialog or overlay on top of the existing content, allowing the user to interact with it while still being able to see the underlying content. This display mode is often used for alerts, confirmations, or when the webview content is related to the current context but does not require full immersion. When a webview event with this display mode is shown, it will typically trigger the WebViewStatusChanged event with a status of "Modal", and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared". + /// + Modal, + } + + /// + /// Defines the target for a webview event, with expected values of "OSD", "Controller", "PersistentWebApp", or "RoomScheduler". + /// + + public enum eWebViewTarget + { + /// + /// The target for the webview event is unknown or not specified. This value can be used as a default or fallback when the target is not provided or cannot be parsed into a known value. + /// + Unknown, + + /// + /// The webview event should be displayed on the On-Screen Display (OSD). + /// + OSD, + + /// + /// The webview event should be displayed on the controller. + /// + Controller, + + /// + /// The webview event should be displayed on the persistent web application. + /// + PersistentWebApp, + + /// + /// The webview event should be displayed on the room scheduler. + /// + RoomScheduler + } + + /// + /// Represents the reason for an error in a webview event, which can provide additional information about what went wrong. This class is typically only used in the Status property of a WebViewEvent when the status indicates an error, and may be null otherwise. + /// + public class Reason + { + /// + /// The reason for an error in a webview event as a string, which can provide additional information about what went wrong. This property is typically only populated in case of an error, and may be null otherwise. + /// + public string Value { get; set; } + } + + /// + /// Represents the XPath of a webview event, which can provide information about where an error occurred in the webview. This class is typically only used in the Status property of a WebViewEvent when the status indicates an error, and may be null otherwise. + /// + public class XPath + { + /// + /// The XPath of a webview event as a string, which can provide information about where an error occurred in the webview. This property is typically only populated in case of an error, and may be null otherwise. + /// + public string Value { get; set; } + } + + /// + /// Represents a base class for properties that have a string value and trigger an action when the value changes. This class can be used as a base for properties like DisplayMode and Target in the WebViewEvent, which have string values that can be set directly or parsed into enums for easier handling of expected values. The ValueChangedAction can be set to trigger any desired behavior when the value changes, such as updating the UI or triggering other events. + /// + public abstract class ValueProperty + { + /// + /// Triggered when Value is set + /// + public Action ValueChangedAction { get; set; } + + /// + /// Triggers the ValueChangedAction if it is set. This method should be called whenever the Value property is set to ensure that any desired behavior associated with a change in value is executed. + /// + protected void OnValueChanged() + { + var a = ValueChangedAction; + if (a != null) + a(); + } + + } + + /// + /// Represents a webview event, which can include information about the status of the webview, the display parameters for the webview, and any error information if applicable. This class can be used to represent both show and clear events for a webview, with the Status property indicating the current status of the webview (e.g., "Fullscreen", "Modal", "Cleared", "Error", or "Unknown"), the Display property providing details about how the webview is being displayed (e.g., mode, URL, target, title), and the Cleared property providing details about a cleared/closed webview event (e.g., target and ID). The Id property can be used to correlate show and clear events for the same webview instance. + /// + public class WebViewEvent + { + /// + /// The unique identifier for the webview event, which can be used to correlate show and clear events for the same webview instance. This property is typically included in both show and clear events for a webview, allowing you to track the lifecycle of a specific webview instance from when it is shown to when it is cleared/closed. The Id can be any string value, but it should be unique for each webview instance to ensure proper correlation between show and clear events. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// The status of the webview event, which can indicate the current state of the webview (e.g., "Fullscreen", "Modal", "Cleared", "Error", or "Unknown") as well as any error information if applicable (XPath and Reason). The Value property can be used to get or set the current status of the webview, while the XPath and Reason properties can provide additional information in case of an error. The StatusString property can be used to get or set the raw status string from the event, but it is recommended to use the Value property for easier handling of expected values. Setting the Value property will trigger the ValueChangedAction if it is set, allowing you to respond to changes in the webview status as needed. + /// + [JsonProperty("status")] + public Status Status { get; set; } // /Event/UserInterface/WebView/Status + + /// + /// The display parameters for the webview event, which can include the display mode (e.g., "Fullscreen", "Modal", or "Unknown"), the URL to display in the webview, the target for the webview (e.g., "OSD", "Controller", "PersistentWebApp", or "RoomScheduler"), and the title to display on the webview. This property is typically included in show events for a webview, providing details about how the webview is being displayed. When a webview event with these display parameters is shown, it will typically trigger the WebViewStatusChanged event with a status of "Fullscreen" or "Modal" depending on the specified display mode, and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared". + /// + [JsonProperty("display")] + public WebViewDisplay Display { get; set; } // /Event/UserInterface/WebView/Display + + /// + /// The details for a cleared/closed webview event, which can include the target for the webview that was cleared (e.g., "OSD", "Controller", "PersistentWebApp", or "RoomScheduler") and the unique identifier for the webview event that was cleared. This property is typically included in clear events for a webview, providing details about which webview instance was cleared/closed. When a webview event with this property is cleared/closed, it will typically trigger the WebViewStatusChanged event with a status of "Cleared". + /// + [JsonProperty("cleared")] + public WebViewClear Cleared { get; set; } // /Event/UserInterface/WebView/Cleared + } + + /// + /// Represents the display parameters for a webview event, which can include the display mode (e.g., "Fullscreen", "Modal", or "Unknown"), the URL to display in the webview, the target for the webview (e.g., "OSD", "Controller", "PersistentWebApp", or "RoomScheduler"), and the title to display on the webview. This class is typically used in the Display property of a WebViewEvent to provide details about how the webview is being displayed when a show event occurs. When a webview event with these display parameters is shown, it will typically trigger the WebViewStatusChanged event with a status of "Fullscreen" or "Modal" depending on the specified display mode, and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared". + /// + public class WebViewDisplay + { + /// + /// The display mode for the webview event. Expected values are "Fullscreen", "Modal", or "Unknown". + /// + [JsonProperty("mode")] + public DisplayMode Mode { get; set; } + + /// + /// The URL to display in the webview. + /// + [JsonProperty("url")] + public string Url { get; set; } + + /// + /// The target for the webview. Expected values are "OSD", "Controller", "PersistentWebApp", or "RoomScheduler". + /// + [JsonProperty("target")] + public Target Target { get; set; } + + /// + /// The title to display on the webview. + /// + [JsonProperty("title")] + public string Title { get; set; } + + /// + /// The unique identifier for the webview event, used to correlate show and clear events for the same webview instance. + /// + [JsonProperty("id")] + public string Id { get; set; } + } + + /// + /// Represents the data for a webview cleared event, which indicates that a webview with the specified ID and target has been cleared/closed. + /// + public class WebViewClear + { + /// + /// The target for the webview that was cleared. Expected values are "OSD", "Controller", "PersistentWebApp", or "RoomScheduler". + /// + [JsonProperty("target")] + public Target Target { get; set; } + + /// + /// The unique identifier for the webview event that was cleared, used to correlate show and clear events for the same webview instance. + /// + [JsonProperty("id")] + public string Id { get; set; } + } + + /// + /// Represents the display mode for a webview event, with a string value and a corresponding enum property for easier handling of expected values. + /// + public class DisplayMode : ValueProperty + { + private string _value; + + /// + /// The id of the webview event. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// The string value for the display mode, which can be set directly or parsed into the WebViewEventMode enum using the WebViewEventMode property. Setting this property will also trigger the ValueChangedAction if it is set. + /// + public string Value { get { return _value; } set { _value = value; OnValueChanged(); } } + + /// + /// The display mode for the webview event as an enum, which can be used for easier handling of expected values. Expected values are Fullscreen, Modal, or Unknown. + /// + public eWebViewEventMode WebViewEventMode + { + get + { + eWebViewEventMode mode; + System.Enum.TryParse(Value, true, out mode); + return mode; + } + } + } + + /// + /// Represents the target for a webview event, with a string value and a corresponding enum property for easier handling of expected values. Setting the Value property will also trigger the ValueChangedAction if it is set. + /// + public class Target : ValueProperty + { + private string _value; + + /// + /// The id of the webview event. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// The string value for the target, which can be set directly or parsed into the eWebViewTarget enum using the WebViewTarget property. Expected values are "OSD", "Controller", "PersistentWebApp", or "RoomScheduler". Setting this property will also trigger the ValueChangedAction if it is set. + /// + public string Value { get { return _value; } set { _value = value; OnValueChanged(); } } + + /// + /// The target for the webview event as an enum, which can be used for easier handling of expected values. Expected values are OSD, Controller, PersistentWebApp, or RoomScheduler. + /// + public eWebViewTarget WebViewTarget + { + get + { + eWebViewTarget target; + System.Enum.TryParse(Value, true, out target); + return target; + } + } + } + + /// + /// Represents the status of a webview event, which can include error information (XPath and Reason) as well as the current status of the webview. The Value property can be used to get or set the current status of the webview, while the XPath and Reason properties can provide additional information in case of an error. The StatusString property can be used to get or set the raw status string from the event. + /// + public class Status + { + /// + /// The XPath of the webview event, which can provide information about where an error occurred in the webview. This property is typically only populated in case of an error, and may be null otherwise. + /// + [JsonProperty("XPath", NullValueHandling = NullValueHandling.Ignore)] + public XPath XPath { get; set; } + + /// + /// The reason for an error in the webview event, which can provide additional information about what went wrong. This property is typically only populated in case of an error, and may be null otherwise. + /// + [JsonProperty("Reason", NullValueHandling = NullValueHandling.Ignore)] + public Reason Reason { get; set; } + + /// + /// The raw status string from the webview event, which can provide information about the current status of the webview. This property can be used to get or set the status directly, but it is recommended to use the Value property for easier handling of expected values. Setting this property will not trigger any actions, while setting the Value property will trigger the ValueChangedAction if it is set. + /// + [JsonProperty("status", NullValueHandling = NullValueHandling.Ignore)] + public string StatusString { get; set; } + + /// + /// The current status of the webview as a string, which can be set directly or parsed into a WebViewEventMode enum using the WebViewEventMode property. Expected values are "Fullscreen", "Modal", "Cleared", "Error", or "Unknown". Setting this property will trigger the ValueChangedAction if it is set. + /// + [JsonProperty("Value", NullValueHandling = NullValueHandling.Ignore)] + public string Value { get; set; } + } + /// /// Defines the contract for IHasWebView /// @@ -34,6 +310,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces /// Event raised when the webview status changes /// event EventHandler WebViewStatusChanged; + } @@ -75,6 +352,11 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces /// public string Status { get; } + /// + /// Gets or sets the WebViewEvent associated with the status change, which can provide additional information about the webview event that triggered the status change, such as display parameters or error information. This property allows you to include the full WebViewEvent in the event args, giving you access to all relevant details about the webview event when handling the WebViewStatusChanged event. + /// + public WebViewEvent WebView { get; } + /// /// Constructor for WebViewStatusChangedEventArgs /// @@ -83,5 +365,16 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces { Status = status; } + + /// + /// Constructor for WebViewStatusChangedEventArgs with WebViewEvent parameter, which can provide additional information about the webview event that triggered the status change, such as display parameters or error information. This constructor allows you to include the full WebViewEvent in the event args, giving you access to all relevant details about the webview event when handling the WebViewStatusChanged event. + /// + /// the new status of the webview + /// the WebViewEvent associated with the status change + public WebViewStatusChangedEventArgs(string status, WebViewEvent webview) + { + Status = status; + WebView = webview; + } } } From bf517ad8b834cbd427246952e073136fab7e6414 Mon Sep 17 00:00:00 2001 From: Erik Meyer Date: Tue, 14 Apr 2026 10:09:52 -0400 Subject: [PATCH 09/46] fix: add url property to WebViewClear --- .../DeviceTypeInterfaces/IHasWebView.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWebView.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWebView.cs index b429a49d..ef766efd 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWebView.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWebView.cs @@ -186,6 +186,12 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces /// [JsonProperty("id")] public string Id { get; set; } + + /// + /// The URL that was displayed in the webview that was cleared. + /// + [JsonProperty("url")] + public string Url { get; set; } } /// From a610e127de5ff0ec6f106cff73f2779b2622b471 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 14 Apr 2026 16:40:37 -0600 Subject: [PATCH 10/46] fix: update version to 2.29.0-local in Directory.Build.props --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 49e41a67..7435df6f 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 2.19.4-local + 2.29.0-local $(Version) PepperDash Technology PepperDash Technology From 6e9480f503cda596ff0fecf5952a6115da4d634e Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 14 Apr 2026 22:12:39 -0600 Subject: [PATCH 11/46] feat: add DeleteAllUiClientsHandler and route for deleting all clients. Resolve issues with client closing websocket connection to DebugWebsocketSink --- .../Logging/DebugWebsocketSink.cs | 310 ++++++++++++------ .../DeleteAllUiClientsHandler.cs | 43 +++ .../MobileControlWebsocketServer.cs | 10 +- 3 files changed, 255 insertions(+), 108 deletions(-) create mode 100644 src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 9c3df14e..12b3d90a 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Serilog; using Serilog.Core; using Serilog.Events; @@ -12,11 +8,11 @@ using Crestron.SimplSharp; using WebSocketSharp; using System.Security.Authentication; using WebSocketSharp.Net; -using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.IO; using Org.BouncyCastle.Asn1.X509; using Serilog.Formatting; -using Newtonsoft.Json.Linq; using Serilog.Formatting.Json; namespace PepperDash.Core @@ -27,18 +23,23 @@ namespace PepperDash.Core public class DebugWebsocketSink : ILogEventSink { private HttpServer _httpsServer; - + private string _path = "/debug/join/"; private const string _certificateName = "selfCres"; private const string _certificatePassword = "cres12345"; - public int Port - { get - { - - if(_httpsServer == null) return 0; + private static string CertPath => + $"{Path.DirectorySeparatorChar}user{Path.DirectorySeparatorChar}{_certificateName}.pfx"; + + + public int Port + { + get + { + + if (_httpsServer == null) return 0; return _httpsServer.Port; - } + } } public string Url @@ -54,60 +55,105 @@ namespace PepperDash.Core /// Gets or sets the IsRunning /// public bool IsRunning { get => _httpsServer?.IsListening ?? false; } - + private readonly ITextFormatter _textFormatter; + /// + /// Initializes a new instance of the class with the specified text formatter. + /// + /// This constructor initializes the WebSocket sink and ensures that a certificate is + /// available for secure communication. If the required certificate does not exist, it will be created + /// automatically. Additionally, the sink is configured to stop the server when the program is + /// stopping. + /// The text formatter used to format log messages. If null, a default JSON formatter is used. public DebugWebsocketSink(ITextFormatter formatProvider) { _textFormatter = formatProvider ?? new JsonFormatter(); - if (!File.Exists($"\\user\\{_certificateName}.pfx")) - CreateCert(null); + if (!File.Exists(CertPath)) + CreateCert(); - CrestronEnvironment.ProgramStatusEventHandler += type => - { - if (type == eProgramStatusEventType.Stopping) - { - StopServer(); - } - }; - } - - private void CreateCert(string[] args) - { try { - //Debug.Console(0,"CreateCert Creating Utility"); - CrestronConsole.PrintLine("CreateCert Creating Utility"); - //var utility = new CertificateUtility(); - var utility = new BouncyCertificate(); - //Debug.Console(0, "CreateCert Calling CreateCert"); - CrestronConsole.PrintLine("CreateCert Calling CreateCert"); - //utility.CreateCert(); + CrestronEnvironment.ProgramStatusEventHandler += type => + { + if (type == eProgramStatusEventType.Stopping) + StopServer(); + }; + } + catch + { + // CrestronEnvironment is not available in test / dev environments — safe to skip. + } + } + + private static void CreateCert() + { + // NOTE: This method is called from the constructor, which is itself called during Debug's static + // constructor before _logger is assigned. Do NOT call any Debug.Log* methods here — use + // CrestronConsole.PrintLine only, to avoid a NullReferenceException that would poison the Debug type. + try + { var ipAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); var hostName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0); var domainName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, 0); - //Debug.Console(0, "DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress); - CrestronConsole.PrintLine(string.Format("DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress)); + CrestronConsole.PrintLine(string.Format("CreateCert: DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress)); - var certificate = utility.CreateSelfSignedCertificate(string.Format("CN={0}.{1}", hostName, domainName), new[] { string.Format("{0}.{1}", hostName, domainName), ipAddress }, new[] { KeyPurposeID.id_kp_serverAuth, KeyPurposeID.id_kp_clientAuth }); - //Crestron fails to let us do this...perhaps it should be done through their Dll's but haven't tested - //Debug.Print($"CreateCert Storing Certificate To My.LocalMachine"); - //utility.AddCertToStore(certificate, StoreName.My, StoreLocation.LocalMachine); - //Debug.Console(0, "CreateCert Saving Cert to \\user\\"); - CrestronConsole.PrintLine("CreateCert Saving Cert to \\user\\"); - utility.CertificatePassword = _certificatePassword; - utility.WriteCertificate(certificate, @"\user\", _certificateName); - //Debug.Console(0, "CreateCert Ending CreateCert"); - CrestronConsole.PrintLine("CreateCert Ending CreateCert"); + var subjectName = string.Format("CN={0}.{1}", hostName, domainName); + var fqdn = string.Format("{0}.{1}", hostName, domainName); + + using (var rsa = RSA.Create(2048)) + { + + var request = new CertificateRequest( + subjectName, + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + // Subject Key Identifier + request.CertificateExtensions.Add( + new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + // Extended Key Usage: server + client auth + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension( + new OidCollection + { + new Oid("1.3.6.1.5.5.7.3.1"), // id-kp-serverAuth + new Oid("1.3.6.1.5.5.7.3.2") // id-kp-clientAuth + }, + false)); + + // Subject Alternative Names: DNS + IP + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName(fqdn); + if (System.Net.IPAddress.TryParse(ipAddress, out var ip)) + sanBuilder.AddIpAddress(ip); + request.CertificateExtensions.Add(sanBuilder.Build()); + + var notBefore = DateTimeOffset.UtcNow; + var notAfter = notBefore.AddYears(2); + + using (var cert = request.CreateSelfSigned(notBefore, notAfter)) + { + + var separator = Path.DirectorySeparatorChar; + var outputPath = string.Format("{0}user{1}{2}.pfx", separator, separator, _certificateName); + + var pfxBytes = cert.Export(X509ContentType.Pfx, _certificatePassword); + File.WriteAllBytes(outputPath, pfxBytes); + + CrestronConsole.PrintLine(string.Format("CreateCert: Certificate written to {0}", outputPath)); + } + } } catch (Exception ex) { - //Debug.Console(0, "WSS CreateCert Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace); - CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace)); + CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed: {0}\r\n{1}", ex.Message, ex.StackTrace)); } } @@ -126,14 +172,37 @@ namespace PepperDash.Core } /// - /// StartServerAndSetPort method + /// Starts the WebSocket server on the specified port and configures it with the appropriate certificate. /// + /// This method initializes the WebSocket server and binds it to the specified port. It + /// also applies the server's certificate for secure communication. Ensure that the port is not already in use + /// and that the certificate file is accessible. + /// The port number on which the WebSocket server will listen. Must be a valid, non-negative port number. public void StartServerAndSetPort(int port) { - Debug.Console(0, "Starting Websocket Server on port: {0}", port); + Debug.LogInformation("Starting Websocket Server on port: {0}", port); - Start(port, $"\\user\\{_certificateName}.pfx", _certificatePassword); + Start(port, CertPath, _certificatePassword); + } + + private static X509Certificate2 LoadOrRecreateCert(string certPath, string certPassword) + { + try + { + // EphemeralKeySet is required on Linux/OpenSSL (Crestron 4-series) to avoid + // key-container persistence failures, and avoids the private key export restriction. + return new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet); + } + catch (Exception ex) + { + // Cert is stale or was generated by an incompatible library (e.g. old BouncyCastle output). + // Delete it, regenerate with the BCL path, and retry once. + CrestronConsole.PrintLine(string.Format("SSL cert load failed ({0}); regenerating...", ex.Message)); + try { File.Delete(certPath); } catch { } + CreateCert(); + return new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet); + } } private void Start(int port, string certPath = "", string certPassword = "") @@ -142,66 +211,37 @@ namespace PepperDash.Core { _httpsServer = new HttpServer(port, true); - if (!string.IsNullOrWhiteSpace(certPath)) { - Debug.Console(0, "Assigning SSL Configuration"); - _httpsServer.SslConfiguration = new ServerSslConfiguration(new X509Certificate2(certPath, certPassword)) - { - ClientCertificateRequired = false, - CheckCertificateRevocation = false, - EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 | SslProtocols.Tls, - //this is just to test, you might want to actually validate - ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => - { - Debug.Console(0, "HTTPS ClientCerticateValidation Callback triggered"); - return true; - } - }; - } - Debug.Console(0, "Adding Debug Client Service"); - _httpsServer.AddWebSocketService(_path); - Debug.Console(0, "Assigning Log Info"); - _httpsServer.Log.Level = LogLevel.Trace; - _httpsServer.Log.Output = (d, s) => - { - uint level; + Debug.LogInformation("Assigning SSL Configuration"); - switch(d.Level) - { - case WebSocketSharp.LogLevel.Fatal: - level = 3; - break; - case WebSocketSharp.LogLevel.Error: - level = 2; - break; - case WebSocketSharp.LogLevel.Warn: - level = 1; - break; - case WebSocketSharp.LogLevel.Info: - level = 0; - break; - case WebSocketSharp.LogLevel.Debug: - level = 4; - break; - case WebSocketSharp.LogLevel.Trace: - level = 5; - break; - default: - level = 4; - break; - } - - Debug.Console(level, "{1} {0}\rCaller:{2}\rMessage:{3}\rs:{4}", d.Level.ToString(), d.Date.ToString(), d.Caller.ToString(), d.Message, s); - }; - Debug.Console(0, "Starting"); + _httpsServer.SslConfiguration.ServerCertificate = LoadOrRecreateCert(certPath, certPassword); + _httpsServer.SslConfiguration.ClientCertificateRequired = false; + _httpsServer.SslConfiguration.CheckCertificateRevocation = false; + _httpsServer.SslConfiguration.EnabledSslProtocols = SslProtocols.Tls12; + //this is just to test, you might want to actually validate + _httpsServer.SslConfiguration.ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + Debug.LogInformation("HTTPS ClientCerticateValidation Callback triggered"); + return true; + }; + } + Debug.LogInformation("Adding Debug Client Service"); + _httpsServer.AddWebSocketService(_path); + Debug.LogInformation("Assigning Log Info"); + _httpsServer.Log.Level = LogLevel.Trace; + _httpsServer.Log.Output = WriteWebSocketInternalLog; + Debug.LogInformation("Starting"); _httpsServer.Start(); - Debug.Console(0, "Ready"); + Debug.LogInformation("Ready"); } catch (Exception ex) { - Debug.Console(0, "WebSocket Failed to start {0}", ex.Message); + Debug.LogError(ex, "WebSocket Failed to start {0}", ex.Message); + Debug.LogVerbose("Stack Trace:\r{0}", ex.StackTrace); + // Null out the server so callers can detect failure via IsRunning / Url null guards. + _httpsServer = null; } } @@ -210,10 +250,68 @@ namespace PepperDash.Core /// public void StopServer() { - Debug.Console(0, "Stopping Websocket Server"); - _httpsServer?.Stop(); + Debug.LogInformation("Stopping Websocket Server"); - _httpsServer = null; + try + { + if (_httpsServer == null || !_httpsServer.IsListening) + { + return; + } + + // Prevent close-sequence internal websocket logs from re-entering the logging pipeline. + _httpsServer.Log.Output = (d, s) => { }; + + var serviceHost = _httpsServer.WebSocketServices[_path]; + + if (serviceHost == null) + { + _httpsServer.Stop(); + _httpsServer = null; + return; + } + + serviceHost.Sessions.Broadcast("Server is stopping"); + + foreach (var session in serviceHost.Sessions.Sessions) + { + if (session?.Context?.WebSocket != null && session.Context.WebSocket.IsAlive) + { + session.Context.WebSocket.Close(1001, "Server is stopping"); + } + } + + _httpsServer.Stop(); + + _httpsServer = null; + + } + catch (Exception ex) + { + Debug.LogError(ex, "WebSocket Failed to stop gracefully {0}", ex.Message); + Debug.LogVerbose("Stack Trace\r\n{0}", ex.StackTrace); + } + } + + private static void WriteWebSocketInternalLog(LogData data, string supplemental) + { + try + { + if (data == null) + { + return; + } + + var message = string.IsNullOrWhiteSpace(data.Message) ? "" : data.Message; + var details = string.IsNullOrWhiteSpace(supplemental) ? string.Empty : string.Format(" | details: {0}", supplemental); + + // Use direct console output to avoid recursive log sink calls. + CrestronConsole.PrintLine(string.Format("WS[{0}] {1} | message: {2}{3}", data.Level, data.Date, message, details)); + } + catch + { + // Never throw from websocket log callback. + } } } diff --git a/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs b/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs new file mode 100644 index 00000000..c08f9af1 --- /dev/null +++ b/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs @@ -0,0 +1,43 @@ +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core; +using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Web; +using PepperDash.Essentials.WebSocketServer; +using Serilog.Events; + +namespace PepperDash.Essentials.WebApiHandlers +{ + /// + /// Represents a DeleteAllUiClientsHandler + /// + public class DeleteAllUiClientsHandler : WebApiBaseRequestHandler + { + private readonly MobileControlWebsocketServer server; + + /// + /// Essentials CWS API handler for the MC Direct Server + /// + /// Direct Server instance + public DeleteAllUiClientsHandler(MobileControlWebsocketServer directServer) : base(true) + { + server = directServer; + } + + /// + /// Deletes all clients from the Direct Server + /// + /// HTTP Context for this request + protected override void HandleDelete(HttpCwsContext context) + { + server.RemoveAllTokens("confirm"); + + var res = context.Response; + res.StatusCode = 200; + res.ContentType = "application/json"; + res.Headers.Add("Content-Type", "application/json"); + res.Write(JsonConvert.SerializeObject(new { success = true }), false); + res.End(); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index 3b55120f..1c9ed37a 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -238,11 +238,17 @@ namespace PepperDash.Essentials.WebSocketServer var routes = new List { - new HttpCwsRoute($"devices/{Key}/client") + new HttpCwsRoute($"device/{Key}/client") { Name = "ClientHandler", RouteHandler = new UiClientHandler(this) }, + + new HttpCwsRoute($"device/{Key}/deleteAllUiClients") + { + Name = "DeleteAllClientsHandler", + RouteHandler = new DeleteAllUiClientsHandler(this) + }, }; apiServer.AddRoute(routes); @@ -908,7 +914,7 @@ namespace PepperDash.Essentials.WebSocketServer /// /// Removes all clients from the server /// - private void RemoveAllTokens(string s) + public void RemoveAllTokens(string s) { if (s == "?" || string.IsNullOrEmpty(s)) { From 9ea5ec5d1a2432fae5b14e2df56ee8de3affdff4 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 16 Apr 2026 21:31:30 -0600 Subject: [PATCH 12/46] feat: implement login functionality with LoginRequestHandler and integrate asset loading --- ...sentialsPlugins-builds-4-series-caller.yml | 3 +- src/PepperDash.Core/Web/WebApiServer.cs | 2 + .../Web/EssentialsWebApi.cs | 5 + .../Web/EssentialsWebApiHelpers.cs | 17 +- .../RequestHandlers/LoginRequestHandler.cs | 122 +++++++++ src/PepperDash.Essentials/AssetLoader.cs | 257 ++++++++++++++++++ src/PepperDash.Essentials/ControlSystem.cs | 140 +--------- 7 files changed, 406 insertions(+), 140 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs create mode 100644 src/PepperDash.Essentials/AssetLoader.cs diff --git a/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml b/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml index 291e9371..d5990e99 100644 --- a/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml +++ b/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml @@ -19,4 +19,5 @@ jobs: version: ${{ needs.getVersion.outputs.version }} tag: ${{ needs.getVersion.outputs.tag }} channel: ${{ needs.getVersion.outputs.channel }} - bypassPackageCheck: true \ No newline at end of file + bypassPackageCheck: true + devToolsVersion: ${{ vars.ESSENTIALSDEVTOOLSVERSION }} \ No newline at end of file diff --git a/src/PepperDash.Core/Web/WebApiServer.cs b/src/PepperDash.Core/Web/WebApiServer.cs index 11ec4e2f..cd113c15 100644 --- a/src/PepperDash.Core/Web/WebApiServer.cs +++ b/src/PepperDash.Core/Web/WebApiServer.cs @@ -99,6 +99,8 @@ namespace PepperDash.Core.Web if (_server == null) _server = new HttpCwsServer(BasePath); + _server.AuthenticateAllRoutes = false; + _server.setProcessName(Key); _server.HttpRequestHandler = new DefaultRequestHandler(); diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index c498fedf..dfed5fdd 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -77,6 +77,11 @@ namespace PepperDash.Essentials.Core.Web { var routes = new List { + new HttpCwsRoute("login") + { + Name = "Root", + RouteHandler = new LoginRequestHandler() + }, new HttpCwsRoute("versions") { Name = "ReportVersions", diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs index 75f90189..397278f5 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs @@ -36,17 +36,25 @@ namespace PepperDash.Essentials.Core.Web }; } + + /// /// MapToDeviceListObject method /// public static object MapToDeviceListObject(IKeyed device) { + var interfaces = device.GetType() + .GetInterfaces() + .Select(i => i.Name) + .ToList(); + return new { device.Key, Name = (device is IKeyName) ? (device as IKeyName).Name - : "---" + : "---", + Interfaces = interfaces }; } @@ -110,5 +118,10 @@ namespace PepperDash.Essentials.Core.Web CType = device.Value.Type == null ? "---": device.Value.Type.ToString() }; } - } + + internal static bool IsAuthenticated(HttpCwsContext context) + { + throw new NotImplementedException(); + } + } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs new file mode 100644 index 00000000..67aded59 --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -0,0 +1,122 @@ + +using System; +using Crestron.SimplSharp.CrestronAuthentication; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core.Web.RequestHandlers; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Represents a LoginRequestHandler + /// + public class LoginRequestHandler : WebApiBaseRequestHandler + { + /// + /// Constructor + /// + /// + /// base(true) enables CORS support by default + /// + public LoginRequestHandler() + : base(true) + { + } + + /// + /// Handles POST method requests for user login and token generation + /// + /// The HTTP context for the request. + protected override void HandlePost(HttpCwsContext context) + { + try + { + if (context.Request.ContentLength < 0) + { + context.Response.StatusCode = 400; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + var data = context.Request.GetRequestBody(); + if (string.IsNullOrEmpty(data)) + { + context.Response.StatusCode = 400; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + var loginRequest = JsonConvert.DeserializeObject(data); + + if (loginRequest == null || string.IsNullOrEmpty(loginRequest.Username) || string.IsNullOrEmpty(loginRequest.Password)) + { + context.Response.StatusCode = 400; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + Authentication.UserToken token; + + try + { + token = Authentication.GetAuthenticationToken(loginRequest.Username, loginRequest.Password); + } + catch (ArgumentException) + { + context.Response.StatusCode = 401; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + if (!token.Valid) + { + context.Response.StatusCode = 401; + context.Response.StatusDescription = "Unauthorized"; + context.Response.End(); + + return; + } + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(JsonConvert.SerializeObject(new { Token = token }, Formatting.Indented), false); + context.Response.End(); + } + catch (System.Exception ex) + { + context.Response.StatusCode = 500; + context.Response.StatusDescription = "Internal Server Error"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(JsonConvert.SerializeObject(new { Error = ex.Message }, Formatting.Indented), false); + context.Response.End(); + } + } + } + + /// + /// Represents a LoginRequest + /// + public class LoginRequest + { + /// + /// Gets or sets the username. + /// + public string Username { get; set; } + + /// + /// Gets or sets the password. + /// + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials/AssetLoader.cs b/src/PepperDash.Essentials/AssetLoader.cs new file mode 100644 index 00000000..d8b9f5c1 --- /dev/null +++ b/src/PepperDash.Essentials/AssetLoader.cs @@ -0,0 +1,257 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using PepperDash.Core; +using Serilog.Events; + +namespace PepperDash.Essentials +{ + /// + /// Handles extracting embedded asset bundles and moving configuration files from the + /// application directory to the program file-path prefix at startup. + /// Implemented using System.IO types so it can run (and be tested) outside + /// of a Crestron runtime environment. + /// + internal static class AssetLoader + { + /// + /// Scans for well-known zip bundles and + /// JSON configuration files and deploys them to . + /// + /// + /// The directory to scan (typically the Crestron application root). + /// + /// + /// The program's runtime working directory (e.g. /nvram/program1/). + /// + internal static void Load(string applicationDirectoryPath, string filePathPrefix) + { + var applicationDirectory = new DirectoryInfo(applicationDirectoryPath); + Debug.LogMessage(LogEventLevel.Information, + "Searching: {applicationDirectory:l} for embedded assets - {Destination}", + applicationDirectory.FullName, filePathPrefix); + + ExtractAssetsZip(applicationDirectory, filePathPrefix); + ExtractHtmlAssetsZip(applicationDirectory, filePathPrefix); + ExtractDevToolsZip(applicationDirectory, filePathPrefix); + MoveConfigurationFile(applicationDirectory, filePathPrefix); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static void ExtractAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var zipFiles = applicationDirectory.GetFiles("assets*.zip"); + + if (zipFiles.Length > 1) + throw new Exception("Multiple assets zip files found. Cannot continue."); + + if (zipFiles.Length == 1) + { + var zipFile = zipFiles[0]; + var assetsRoot = Path.GetFullPath(filePathPrefix); + if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + assetsRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName); + + using (var archive = ZipFile.OpenRead(zipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(filePathPrefix, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (Directory.Exists(destinationPath)) + Directory.Delete(destinationPath, recursive: true); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in zipFiles) + File.Delete(file.FullName); + } + + private static void ExtractHtmlAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip"); + + if (htmlZipFiles.Length > 1) + throw new Exception( + "Multiple htmlassets zip files found in application directory. " + + "Please ensure only one htmlassets*.zip file is present and retry."); + + if (htmlZipFiles.Length == 1) + { + var htmlZipFile = htmlZipFiles[0]; + var programDir = new DirectoryInfo( + filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; + if (rootDir == null) + throw new Exception( + $"Unable to determine root directory for html extraction. Current path: {filePathPrefix}"); + + var htmlDir = Path.Combine(rootDir.FullName, "html"); + var htmlRoot = Path.GetFullPath(htmlDir); + if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + htmlRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName); + + using (var archive = ZipFile.OpenRead(htmlZipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(htmlDir, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + + var parentDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in htmlZipFiles) + File.Delete(file.FullName); + } + + private static void ExtractDevToolsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var devToolsZipFiles = applicationDirectory.GetFiles("essentials-devtools*.zip"); + + if (devToolsZipFiles.Length > 1) + throw new Exception( + "Multiple essentials-devtools zip files found in application directory. " + + "Please ensure only one essentials-devtools*.zip file is present and retry."); + + if (devToolsZipFiles.Length == 1) + { + var devToolsZipFile = devToolsZipFiles[0]; + var programDir = new DirectoryInfo( + filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; + if (rootDir == null) + throw new Exception( + $"Unable to determine root directory for debug html extraction. Current path: {filePathPrefix}"); + + var debugDir = Path.Combine(rootDir.FullName, "html", "debug"); + var debugRoot = Path.GetFullPath(debugDir); + if (!debugRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !debugRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + debugRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found essentials-devtools zip file: {zipFile:l}... Unzipping to {Destination}...", + devToolsZipFile.FullName, debugDir); + + using (var archive = ZipFile.OpenRead(devToolsZipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(debugDir, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(debugRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + + var parentDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in devToolsZipFiles) + File.Delete(file.FullName); + } + + private static void MoveConfigurationFile(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json"); + + if (jsonFiles.Length > 1) + { + Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", + jsonFiles.Select(f => f.FullName).ToArray()); + throw new Exception("Multiple configuration files found. Cannot continue."); + } + + if (jsonFiles.Length == 1) + { + var jsonFile = jsonFiles[0]; + var finalPath = Path.Combine(filePathPrefix, jsonFile.Name); + Debug.LogMessage(LogEventLevel.Information, + "Found configuration file: {jsonFile:l}... Moving to: {Destination}", + jsonFile.FullName, finalPath); + + if (File.Exists(finalPath)) + { + Debug.LogMessage(LogEventLevel.Information, + "Removing existing configuration file: {Destination}", finalPath); + File.Delete(finalPath); + } + + jsonFile.MoveTo(finalPath); + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index 5fb7771e..815aaa41 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -245,7 +245,7 @@ namespace PepperDash.Essentials // _ = new ProcessorExtensionDeviceFactory(); // _ = new MobileControlFactory(); - LoadAssets(); + LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix); Debug.LogMessage(LogEventLevel.Information, "Starting Essentials load from configuration"); @@ -825,142 +825,8 @@ namespace PepperDash.Essentials } } - private static void LoadAssets() - { - var applicationDirectory = new DirectoryInfo(Global.ApplicationDirectoryPathPrefix); - Debug.LogMessage(LogEventLevel.Information, "Searching: {applicationDirectory:l} for embedded assets - {Destination}", applicationDirectory.FullName, Global.FilePathPrefix); + internal static void LoadAssets(string applicationDirectoryPath, string filePathPrefix) => + AssetLoader.Load(applicationDirectoryPath, filePathPrefix); - var zipFiles = applicationDirectory.GetFiles("assets*.zip"); - - if (zipFiles.Length > 1) - { - throw new Exception("Multiple assets zip files found. Cannot continue."); - } - - if (zipFiles.Length == 1) - { - var zipFile = zipFiles[0]; - var assetsRoot = System.IO.Path.GetFullPath(Global.FilePathPrefix); - if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) - { - assetsRoot += Path.DirectorySeparatorChar; - } - Debug.LogMessage(LogEventLevel.Information, "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName); - using (var archive = ZipFile.OpenRead(zipFile.FullName)) - { - foreach (var entry in archive.Entries) - { - var destinationPath = Path.Combine(Global.FilePathPrefix, entry.FullName); - var fullDest = System.IO.Path.GetFullPath(destinationPath); - if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory."); - - if (string.IsNullOrEmpty(entry.Name)) - { - Directory.CreateDirectory(destinationPath); - continue; - } - - // If a directory exists where a file should go, delete it - if (Directory.Exists(destinationPath)) - Directory.Delete(destinationPath, true); - - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); - entry.ExtractToFile(destinationPath, true); - Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); - } - } - } - - // cleaning up zip files - foreach (var file in zipFiles) - { - File.Delete(file.FullName); - } - - var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip"); - - if (htmlZipFiles.Length > 1) - { - throw new Exception("Multiple htmlassets zip files found in application directory. Please ensure only one htmlassets*.zip file is present and retry."); - } - - - if (htmlZipFiles.Length == 1) - { - var htmlZipFile = htmlZipFiles[0]; - var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - var userOrNvramDir = programDir.Parent; - var rootDir = userOrNvramDir?.Parent; - if (rootDir == null) - { - throw new Exception($"Unable to determine root directory for html extraction. Current path: {Global.FilePathPrefix}"); - } - var htmlDir = Path.Combine(rootDir.FullName, "html"); - var htmlRoot = System.IO.Path.GetFullPath(htmlDir); - if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && - !htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) - { - htmlRoot += Path.DirectorySeparatorChar; - } - Debug.LogMessage(LogEventLevel.Information, "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName); - using (var archive = ZipFile.OpenRead(htmlZipFile.FullName)) - { - foreach (var entry in archive.Entries) - { - var destinationPath = Path.Combine(htmlDir, entry.FullName); - var fullDest = System.IO.Path.GetFullPath(destinationPath); - if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory."); - - if (string.IsNullOrEmpty(entry.Name)) - { - Directory.CreateDirectory(destinationPath); - continue; - } - - // Only delete the file if it exists and is a file, not a directory - if (File.Exists(destinationPath)) - File.Delete(destinationPath); - - var parentDir = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(parentDir)) - Directory.CreateDirectory(parentDir); - - entry.ExtractToFile(destinationPath, true); - Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); - } - } - } - - // cleaning up html zip files - foreach (var file in htmlZipFiles) - { - File.Delete(file.FullName); - } - - var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json"); - - if (jsonFiles.Length > 1) - { - Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", jsonFiles.Select(f => f.FullName).ToArray()); - throw new Exception("Multiple configuration files found. Cannot continue."); - } - - if (jsonFiles.Length == 1) - { - var jsonFile = jsonFiles[0]; - var finalPath = Path.Combine(Global.FilePathPrefix, jsonFile.Name); - Debug.LogMessage(LogEventLevel.Information, "Found configuration file: {jsonFile:l}... Moving to: {Destination}", jsonFile.FullName, finalPath); - - if (File.Exists(finalPath)) - { - Debug.LogMessage(LogEventLevel.Information, "Removing existing configuration file: {Destination}", finalPath); - File.Delete(finalPath); - } - - jsonFile.MoveTo(finalPath); - } - } } } From 4a59cf9f813b975988f610585e4a2d8c226e6913 Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Fri, 17 Apr 2026 10:02:04 -0500 Subject: [PATCH 13/46] chore: remove deprecated Debug.Console methods --- src/PepperDash.Core/Web/WebApiServer.cs | 94 +++++++++++-------------- 1 file changed, 40 insertions(+), 54 deletions(-) diff --git a/src/PepperDash.Core/Web/WebApiServer.cs b/src/PepperDash.Core/Web/WebApiServer.cs index cd113c15..62e577ca 100644 --- a/src/PepperDash.Core/Web/WebApiServer.cs +++ b/src/PepperDash.Core/Web/WebApiServer.cs @@ -5,6 +5,7 @@ using Crestron.SimplSharp; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using PepperDash.Core.Logging; using PepperDash.Core.Web.RequestHandlers; namespace PepperDash.Core.Web @@ -25,29 +26,26 @@ namespace PepperDash.Core.Web private readonly CCriticalSection _serverLock = new CCriticalSection(); private HttpCwsServer _server; - /// - /// Gets or sets the Key - /// + /// + /// Gets or sets the Key + /// public string Key { get; private set; } - /// - /// Gets or sets the Name - /// + /// + /// Gets or sets the Name + /// public string Name { get; private set; } - /// - /// Gets or sets the BasePath - /// + /// + /// Gets or sets the BasePath + /// public string BasePath { get; private set; } - /// - /// Gets or sets the IsRegistered - /// + /// + /// Gets or sets the IsRegistered + /// public bool IsRegistered { get; private set; } - /// - /// Http request handler - /// //public IHttpCwsHandler HttpRequestHandler //{ // get { return _server.HttpRequestHandler; } @@ -58,9 +56,6 @@ namespace PepperDash.Core.Web // } //} - /// - /// Received request event handler - /// //public event EventHandler ReceivedRequestEvent //{ // add { _server.ReceivedRequestEvent += new HttpCwsRequestEventHandler(value); } @@ -91,7 +86,7 @@ namespace PepperDash.Core.Web /// /// /// - public WebApiServer(string key, string name, string basePath) + public WebApiServer(string key, string name, string basePath) { Key = key; Name = string.IsNullOrEmpty(name) ? DefaultName : name; @@ -116,7 +111,7 @@ namespace PepperDash.Core.Web { if (programEventType != eProgramStatusEventType.Stopping) return; - Debug.Console(DebugInfo, this, "Program stopping. stopping server"); + this.LogInformation("Program stopping. stopping server"); Stop(); } @@ -130,18 +125,18 @@ namespace PepperDash.Core.Web // Re-enable the server if the link comes back up and the status should be connected if (ethernetEventArgs.EthernetEventType == eEthernetEventType.LinkUp && IsRegistered) { - Debug.Console(DebugInfo, this, "Ethernet link up. Server is alreedy registered."); + this.LogInformation("Ethernet link up. Server is alreedy registered."); return; } - Debug.Console(DebugInfo, this, "Ethernet link up. Starting server"); + this.LogInformation("Ethernet link up. Starting server"); Start(); } - /// - /// Initialize method - /// + /// + /// Initialize method + /// public void Initialize(string key, string basePath) { Key = key; @@ -155,7 +150,7 @@ namespace PepperDash.Core.Web { if (route == null) { - Debug.Console(DebugInfo, this, "Failed to add route, route parameter is null"); + this.LogWarning("Failed to add route, route parameter is null"); return; } @@ -167,23 +162,20 @@ namespace PepperDash.Core.Web /// Removes a route from CWS /// /// - /// - /// RemoveRoute method - /// public void RemoveRoute(HttpCwsRoute route) { if (route == null) { - Debug.Console(DebugInfo, this, "Failed to remote route, orute parameter is null"); + this.LogWarning("Failed to remove route, route parameter is null"); return; } _server.Routes.Remove(route); } - /// - /// GetRouteCollection method - /// + /// + /// GetRouteCollection method + /// public HttpCwsRouteCollection GetRouteCollection() { return _server.Routes; @@ -200,26 +192,24 @@ namespace PepperDash.Core.Web if (_server == null) { - Debug.Console(DebugInfo, this, "Server is null, unable to start"); + this.LogWarning("Server is null, unable to start"); return; } if (IsRegistered) { - Debug.Console(DebugInfo, this, "Server has already been started"); + this.LogWarning("Server has already been started"); return; } IsRegistered = _server.Register(); - Debug.Console(DebugInfo, this, "Starting server, registration {0}", IsRegistered ? "was successful" : "failed"); + this.LogInformation("Starting server, registration {registrationResult}", IsRegistered ? "was successful" : "failed"); } catch (Exception ex) { - Debug.Console(DebugInfo, this, "Start Exception Message: {0}", ex.Message); - Debug.Console(DebugVerbose, this, "Start Exception StackTrace: {0}", ex.StackTrace); - if (ex.InnerException != null) - Debug.Console(DebugVerbose, this, "Start Exception InnerException: {0}", ex.InnerException); + this.LogError("Start Exception Message: {message}", ex.Message); + this.LogDebug(ex, "Start Exception StackTrace"); } finally { @@ -227,9 +217,9 @@ namespace PepperDash.Core.Web } } - /// - /// Stop method - /// + /// + /// Stop method + /// public void Stop() { try @@ -238,23 +228,21 @@ namespace PepperDash.Core.Web if (_server == null) { - Debug.Console(DebugInfo, this, "Server is null or has already been stopped"); + this.LogWarning("Server is null or has already been stopped"); return; } IsRegistered = _server.Unregister() == false; - Debug.Console(DebugInfo, this, "Stopping server, unregistration {0}", IsRegistered ? "failed" : "was successful"); + this.LogInformation("Stopping server, unregistration {unregistrationResult}", IsRegistered ? "failed" : "was successful"); _server.Dispose(); _server = null; } catch (Exception ex) { - Debug.Console(DebugInfo, this, "Server Stop Exception Message: {0}", ex.Message); - Debug.Console(DebugVerbose, this, "Server Stop Exception StackTrace: {0}", ex.StackTrace); - if (ex.InnerException != null) - Debug.Console(DebugVerbose, this, "Server Stop Exception InnerException: {0}", ex.InnerException); + this.LogError("Server Stop Exception Message: {message}", ex.Message); + this.LogDebug(ex, "Server Stop Exception StackTrace"); } finally { @@ -275,14 +263,12 @@ namespace PepperDash.Core.Web try { var j = JsonConvert.SerializeObject(args.Context, Formatting.Indented); - Debug.Console(DebugVerbose, this, "RecieveRequestEventHandler Context:\x0d\x0a{0}", j); + this.LogVerbose("RecieveRequestEventHandler Context:\x0d\x0a{0}", j); } catch (Exception ex) { - Debug.Console(DebugInfo, this, "ReceivedRequestEventHandler Exception Message: {0}", ex.Message); - Debug.Console(DebugVerbose, this, "ReceivedRequestEventHandler Exception StackTrace: {0}", ex.StackTrace); - if (ex.InnerException != null) - Debug.Console(DebugVerbose, this, "ReceivedRequestEventHandler Exception InnerException: {0}", ex.InnerException); + this.LogError("ReceivedRequestEventHandler Exception Message: {message}", ex.Message); + this.LogDebug(ex, "ReceivedRequestEventHandler Exception StackTrace: {stackTrace}", ex.StackTrace); } } } From db14a614bc2b06926102ee9d934102081ad4b28d Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Fri, 17 Apr 2026 10:02:19 -0500 Subject: [PATCH 14/46] fix: move to concurrent dictionary instead of hash set for impossible routes --- src/PepperDash.Essentials.Core/Routing/Extensions.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 72dd8d07..e4ff143c 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -56,8 +57,9 @@ namespace PepperDash.Essentials.Core /// /// 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 HashSet _impossibleRoutes = new HashSet(); + private static readonly ConcurrentDictionary _impossibleRoutes = new ConcurrentDictionary(); /// /// Indexes all TieLines by source and destination device keys for faster lookups. @@ -579,7 +581,7 @@ namespace PepperDash.Essentials.Core // Check if this route has already been determined to be impossible var routeKey = GetRouteKey(source.Key, destination.Key, signalType); - if (_impossibleRoutes.Contains(routeKey)) + if (_impossibleRoutes.ContainsKey(routeKey)) { Debug.LogMessage(LogEventLevel.Verbose, "Route {0} is cached as impossible, skipping", null, routeKey); return false; @@ -681,7 +683,7 @@ namespace PepperDash.Essentials.Core Debug.LogMessage(LogEventLevel.Verbose, "No route found to {0}", destination, source.Key); // Cache this as an impossible route - _impossibleRoutes.Add(routeKey); + _impossibleRoutes.TryAdd(routeKey, 0); return false; } From 2197dc489d0187661900bd7e6e53af2f86bbffab Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Fri, 17 Apr 2026 10:27:54 -0500 Subject: [PATCH 15/46] fix: pre-built routes now respect source ports for finding routes --- .../Routing/Extensions.cs | 99 ++++++++++--------- .../Routing/RouteDescriptor.cs | 37 +++++-- .../Routing/RouteDescriptorCollection.cs | 15 ++- .../Web/EssentialsWebApi.cs | 2 +- src/PepperDash.Essentials/ControlSystem.cs | 7 +- 5 files changed, 94 insertions(+), 66 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index e4ff143c..631ebd99 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -3,10 +3,8 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using Crestron.SimplSharpPro.Keypads; using PepperDash.Essentials.Core.Queues; using PepperDash.Essentials.Core.Routing; -using Serilog.Events; using Debug = PepperDash.Core.Debug; @@ -69,7 +67,7 @@ namespace PepperDash.Essentials.Core { try { - Debug.LogMessage(LogEventLevel.Information, "Indexing TieLines for faster route discovery"); + Debug.LogInformation("Indexing TieLines for faster route discovery"); _tieLinesByDestination = TieLineCollection.Default .GroupBy(t => t.DestinationPort.ParentDevice.Key) @@ -79,8 +77,8 @@ namespace PepperDash.Essentials.Core .GroupBy(t => t.SourcePort.ParentDevice.Key) .ToDictionary(g => g.Key, g => g.ToList()); - Debug.LogMessage(LogEventLevel.Information, "TieLine indexing complete. {0} destination keys, {1} source keys", - null, _tieLinesByDestination.Count, _tieLinesBySource.Count); + Debug.LogInformation("TieLine indexing complete. {0} destination keys, {1} source keys", + _tieLinesByDestination.Count, _tieLinesBySource.Count); } catch (Exception ex) { @@ -128,11 +126,13 @@ namespace PepperDash.Essentials.Core /// /// 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, eRoutingSignalType type) + private static string GetRouteKey(string sourceKey, string destKey, string sourcePortKey, string destinationPortKey, eRoutingSignalType type) { - return string.Format("{0}|{1}|{2}", sourceKey, destKey, type); + return $"{sourceKey}|{destKey}|{sourcePortKey}|{destinationPortKey}|{type}"; } /// @@ -141,7 +141,7 @@ namespace PepperDash.Essentials.Core public static void ClearImpossibleRoutesCache() { _impossibleRoutes.Clear(); - Debug.LogMessage(LogEventLevel.Information, "Impossible routes cache cleared"); + Debug.LogInformation("Impossible routes cache cleared"); } /// @@ -153,7 +153,7 @@ namespace PepperDash.Essentials.Core { // Remove this line before committing!!!!! var frame = new StackFrame(1, true); - Debug.LogMessage(LogEventLevel.Information, "ReleaseAndMakeRoute Called from {method} with params {destinationKey}:{sourceKey}:{signalType}:{destinationPortKey}:{sourcePortKey}", frame.GetMethod().Name, destination.Key, source.Key, signalType.ToString(), destinationPortKey, sourcePortKey); + Debug.LogInformation("ReleaseAndMakeRoute Called from {method} with params {destinationKey}:{sourceKey}:{signalType}:{destinationPortKey}:{sourcePortKey}", frame.GetMethod().Name, destination.Key, source.Key, signalType.ToString(), destinationPortKey, sourcePortKey); var inputPort = string.IsNullOrEmpty(destinationPortKey) ? null : destination.InputPorts.FirstOrDefault(p => p.Key == destinationPortKey); var outputPort = string.IsNullOrEmpty(sourcePortKey) ? null : source.OutputPorts.FirstOrDefault(p => p.Key == sourcePortKey); @@ -211,13 +211,13 @@ namespace PepperDash.Essentials.Core /// destination device key public static void RemoveRouteRequestForDestination(string destinationKey) { - Debug.LogMessage(LogEventLevel.Information, "Removing route request for {destination}", null, destinationKey); + Debug.LogInformation("Removing route request for {destination}", destinationKey); var result = RouteRequests.Remove(destinationKey); var messageTemplate = result ? "Route Request for {destination} removed" : "Route Request for {destination} not found"; - Debug.LogMessage(LogEventLevel.Information, messageTemplate, null, destinationKey); + Debug.LogInformation(messageTemplate, destinationKey); } /// @@ -233,8 +233,8 @@ namespace PepperDash.Essentials.Core if (!signalType.HasFlag(eRoutingSignalType.AudioVideo) && !(signalType.HasFlag(eRoutingSignalType.Video) && signalType.HasFlag(eRoutingSignalType.SecondaryAudio))) { - var singleTypeRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, signalType); - Debug.LogMessage(LogEventLevel.Debug, "Attempting to build source route from {sourceKey} of type {type}", destination, source.Key, signalType); + var singleTypeRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, signalType); + Debug.LogDebug(destination, "Attempting to build source route from {sourceKey} of type {type}", source.Key, signalType); if (!destination.GetRouteToSource(source, null, null, signalType, 0, singleTypeRouteDescriptor, destinationPort, sourcePort)) singleTypeRouteDescriptor = null; @@ -242,46 +242,46 @@ namespace PepperDash.Essentials.Core var routes = singleTypeRouteDescriptor?.Routes ?? new List(); foreach (var route in routes) { - Debug.LogMessage(LogEventLevel.Verbose, "Route for device: {route}", destination, route.ToString()); + Debug.LogVerbose(destination, "Route for device: {route}", route.ToString()); } return (singleTypeRouteDescriptor, null); } // otherwise, audioVideo needs to be handled as two steps. - Debug.LogMessage(LogEventLevel.Debug, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", destination, source.Key, signalType); + Debug.LogDebug(destination, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", source.Key, signalType); RouteDescriptor audioRouteDescriptor; if (signalType.HasFlag(eRoutingSignalType.SecondaryAudio)) { - audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.SecondaryAudio); + audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.SecondaryAudio); } else { - audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.Audio); + audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.Audio); } var audioSuccess = destination.GetRouteToSource(source, null, null, signalType.HasFlag(eRoutingSignalType.SecondaryAudio) ? eRoutingSignalType.SecondaryAudio : eRoutingSignalType.Audio, 0, audioRouteDescriptor, destinationPort, sourcePort); if (!audioSuccess) - Debug.LogMessage(LogEventLevel.Debug, "Cannot find audio route to {0}", destination, source.Key); + Debug.LogDebug(destination, "Cannot find audio route to {0}", source.Key); - var videoRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.Video); + var videoRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.Video); var videoSuccess = destination.GetRouteToSource(source, null, null, eRoutingSignalType.Video, 0, videoRouteDescriptor, destinationPort, sourcePort); if (!videoSuccess) - Debug.LogMessage(LogEventLevel.Debug, "Cannot find video route to {0}", destination, source.Key); + Debug.LogDebug(destination, "Cannot find video route to {0}", source.Key); foreach (var route in audioRouteDescriptor.Routes) { - Debug.LogMessage(LogEventLevel.Verbose, "Audio route for device: {route}", destination, route.ToString()); + Debug.LogVerbose(destination, "Audio route for device: {route}", route.ToString()); } foreach (var route in videoRouteDescriptor.Routes) { - Debug.LogMessage(LogEventLevel.Verbose, "Video route for device: {route}", destination, route.ToString()); + Debug.LogVerbose(destination, "Video route for device: {route}", route.ToString()); } @@ -306,8 +306,8 @@ namespace PepperDash.Essentials.Core { if (destination == null) throw new ArgumentNullException(nameof(destination)); if (source == null) throw new ArgumentNullException(nameof(source)); - if (destinationPort == null) Debug.LogMessage(LogEventLevel.Information, "Destination port is null"); - if (sourcePort == null) Debug.LogMessage(LogEventLevel.Information, "Source port is null"); + if (destinationPort == null) Debug.LogDebug("Destination port is null"); + if (sourcePort == null) Debug.LogDebug("Source port is null"); var routeRequest = new RouteRequest { @@ -329,7 +329,7 @@ namespace PepperDash.Essentials.Core RouteRequests[destination.Key] = routeRequest; - Debug.LogMessage(LogEventLevel.Information, "Device: {destination} is cooling down and already has a routing request stored. Storing new route request to route to source key: {sourceKey}", null, destination.Key, routeRequest.Source.Key); + Debug.LogInformation("Device: {destination} is cooling down and already has a routing request stored. Storing new route request to route to source key: {sourceKey}", destination.Key, routeRequest.Source.Key); return; } @@ -341,7 +341,7 @@ namespace PepperDash.Essentials.Core RouteRequests.Add(destination.Key, routeRequest); - Debug.LogMessage(LogEventLevel.Information, "Device: {destination} is cooling down. Storing route request to route to source key: {sourceKey}", null, destination.Key, routeRequest.Source.Key); + Debug.LogInformation("Device: {destination} is cooling down. Storing route request to route to source key: {sourceKey}", destination.Key, routeRequest.Source.Key); return; } @@ -353,7 +353,7 @@ namespace PepperDash.Essentials.Core RouteRequests.Remove(destination.Key); - Debug.LogMessage(LogEventLevel.Information, "Device: {destination} is NOT cooling down. Removing stored route request and routing to source key: {sourceKey}", null, destination.Key, routeRequest.Source.Key); + Debug.LogInformation("Device: {destination} is NOT cooling down. Removing stored route request and routing to source key: {sourceKey}", destination.Key, routeRequest.Source.Key); } routeRequestQueue.Enqueue(new ReleaseRouteQueueItem(ReleaseRouteInternal, destination, destinationPort?.Key ?? string.Empty, false)); @@ -469,7 +469,8 @@ namespace PepperDash.Essentials.Core audioOrSingleRoute = audioCollection.Descriptors.FirstOrDefault(d => d.Source.Key == request.Source.Key && d.Destination.Key == request.Destination.Key && - (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key)); + (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key) && + (request.SourcePort == null || d.OutputPort?.Key == request.SourcePort.Key)); } if (RouteDescriptors.TryGetValue(eRoutingSignalType.Video, out RouteDescriptorCollection videoCollection)) @@ -477,7 +478,8 @@ namespace PepperDash.Essentials.Core videoRoute = videoCollection.Descriptors.FirstOrDefault(d => d.Source.Key == request.Source.Key && d.Destination.Key == request.Destination.Key && - (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key)); + (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key) && + (request.SourcePort == null || d.OutputPort?.Key == request.SourcePort.Key)); } } else @@ -492,14 +494,15 @@ namespace PepperDash.Essentials.Core audioOrSingleRoute = collection.Descriptors.FirstOrDefault(d => d.Source.Key == request.Source.Key && d.Destination.Key == request.Destination.Key && - (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key)); + (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key) && + (request.SourcePort == null || d.OutputPort?.Key == request.SourcePort.Key)); } } // If no pre-loaded route found, build it dynamically if (audioOrSingleRoute == null && videoRoute == null) { - Debug.LogMessage(LogEventLevel.Debug, "No pre-loaded route found, building dynamically", request.Destination); + Debug.LogDebug(request.Destination, "No pre-loaded route found, building dynamically"); (audioOrSingleRoute, videoRoute) = request.Destination.GetRouteToSource(request.Source, request.SignalType, request.DestinationPort, request.SourcePort); } @@ -513,14 +516,15 @@ namespace PepperDash.Essentials.Core RouteDescriptorCollection.DefaultCollection.AddRouteDescriptor(videoRoute); } - Debug.LogMessage(LogEventLevel.Verbose, "Executing full route", request.Destination); + Debug.LogVerbose(request.Destination, "Executing full route"); audioOrSingleRoute.ExecuteRoutes(); videoRoute?.ExecuteRoutes(); } catch (Exception ex) { - Debug.LogMessage(ex, "Exception Running Route Request {request}", null, request); + Debug.LogError("Exception Running Route Request {request}: {exception}", request, ex.Message); + Debug.LogDebug(ex, "Stack Trace: "); } } @@ -534,7 +538,7 @@ namespace PepperDash.Essentials.Core { try { - Debug.LogMessage(LogEventLevel.Information, "Release route for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); + Debug.LogInformation(destination, "Release route for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); if (RouteRequests.TryGetValue(destination.Key, out RouteRequest existingRequest) && destination is IWarmingCooling) { @@ -548,13 +552,14 @@ namespace PepperDash.Essentials.Core var current = RouteDescriptorCollection.DefaultCollection.RemoveRouteDescriptor(destination, inputPortKey); if (current != null) { - Debug.LogMessage(LogEventLevel.Information, "Releasing current route: {0}", destination, current.Source.Key); + Debug.LogInformation(destination, "Releasing current route: {0}", current.Source.Key); current.ReleaseRoutes(clearRoute); } } catch (Exception ex) { - Debug.LogMessage(ex, "Exception releasing route for '{destination}':'{inputPortKey}'", null, destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); + Debug.LogError("Exception releasing route for '{destination}':'{inputPortKey}': {exception}", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey, ex.Message); + Debug.LogDebug(ex, "Stack Trace: "); } } @@ -580,14 +585,14 @@ namespace PepperDash.Essentials.Core cycle++; // Check if this route has already been determined to be impossible - var routeKey = GetRouteKey(source.Key, destination.Key, signalType); + var routeKey = GetRouteKey(source.Key, destination.Key, sourcePort?.Key ?? "auto", destinationPort?.Key ?? "auto", signalType); if (_impossibleRoutes.ContainsKey(routeKey)) { - Debug.LogMessage(LogEventLevel.Verbose, "Route {0} is cached as impossible, skipping", null, routeKey); + Debug.LogVerbose("Route {0} is cached as impossible, skipping", routeKey); return false; } - Debug.LogMessage(LogEventLevel.Verbose, "GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString()); + 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; @@ -635,7 +640,7 @@ namespace PepperDash.Essentials.Core } else // no direct-connect. Walk back devices. { - Debug.LogMessage(LogEventLevel.Verbose, "is not directly connected to {sourceKey}. Walking down tie lines", destination, source.Key); + Debug.LogVerbose(destination, "is not directly connected to {sourceKey}. Walking down tie lines", source.Key); // No direct tie? Run back out on the inputs' attached devices... // Only the ones that are routing devices @@ -653,13 +658,13 @@ namespace PepperDash.Essentials.Core // Check if this previous device has already been walked if (alreadyCheckedDevices.Contains(midpointDevice)) { - Debug.LogMessage(LogEventLevel.Verbose, "Skipping input {midpointDeviceKey} on {destinationKey}, this was already checked", destination, midpointDevice.Key, destination.Key); + Debug.LogVerbose(destination, "Skipping input {midpointDeviceKey} on {destinationKey}, this was already checked", midpointDevice.Key, destination.Key); continue; } var midpointOutputPort = tieLine.SourcePort; - Debug.LogMessage(LogEventLevel.Verbose, "Trying to find route on {midpointDeviceKey}", destination, midpointDevice.Key); + Debug.LogVerbose(destination, "Trying to find route on {midpointDeviceKey}", midpointDevice.Key); // haven't seen this device yet. Do it. Pass the output port to the next // level to enable switching on success @@ -668,9 +673,9 @@ namespace PepperDash.Essentials.Core if (upstreamRoutingSuccess) { - Debug.LogMessage(LogEventLevel.Verbose, "Upstream device route found", destination); - Debug.LogMessage(LogEventLevel.Verbose, "Route found on {midpointDeviceKey}", destination, midpointDevice.Key); - Debug.LogMessage(LogEventLevel.Verbose, "TieLine: SourcePort: {SourcePort} DestinationPort: {DestinationPort}", destination, tieLine.SourcePort, tieLine.DestinationPort); + Debug.LogVerbose(destination, "Upstream device route found"); + Debug.LogVerbose(destination, "Route found on {midpointDeviceKey}", midpointDevice.Key); + Debug.LogVerbose(destination, "TieLine: SourcePort: {SourcePort} DestinationPort: {DestinationPort}", tieLine.SourcePort, tieLine.DestinationPort); goodInputPort = tieLine.DestinationPort; break; // Stop looping the inputs in this cycle } @@ -680,7 +685,7 @@ namespace PepperDash.Essentials.Core if (goodInputPort == null) { - Debug.LogMessage(LogEventLevel.Verbose, "No route found to {0}", destination, source.Key); + Debug.LogVerbose(destination, "No route found to {0}", source.Key); // Cache this as an impossible route _impossibleRoutes.TryAdd(routeKey, 0); @@ -700,7 +705,7 @@ namespace PepperDash.Essentials.Core routeTable.Routes.Add(new RouteSwitchDescriptor(outputPortToUse, goodInputPort)); } else // device is merely IRoutingInputOutputs - Debug.LogMessage(LogEventLevel.Verbose, "No routing. Passthrough device", destination); + Debug.LogVerbose(destination, "No routing. Passthrough device"); return true; } diff --git a/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs b/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs index c35b0afa..e698ae0d 100644 --- a/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs +++ b/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs @@ -20,22 +20,27 @@ namespace PepperDash.Essentials.Core public IRoutingInputs Destination { get; private set; } /// - /// Gets or sets the InputPort + /// The InputPort on the destination device for this route, if applicable. May be null if the route is not for a specific input port. /// public RoutingInputPort InputPort { get; private set; } /// - /// Gets or sets the Source + /// Gets the source device (sink or midpoint) for the route. /// public IRoutingOutputs Source { get; private set; } /// - /// Gets or sets the SignalType + /// Gets the OutputPort on the source device for this route, if applicable. May be null if the route is not for a specific output port. + /// + public RoutingOutputPort OutputPort { get; private set; } + + /// + /// Gets the signal type for this route. /// public eRoutingSignalType SignalType { get; private set; } /// - /// Gets or sets the Routes + /// Gets the collection of route switch descriptors for this route. /// public List Routes { get; private set; } @@ -56,11 +61,24 @@ namespace PepperDash.Essentials.Core /// The destination device. /// The destination input port (optional). /// The signal type for this route. - public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, eRoutingSignalType signalType) + public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, eRoutingSignalType signalType) : this(source, destination, inputPort, null, signalType) + { + } + + /// + /// Initializes a new instance of the class for a route with specific destination input and source output ports. + /// + /// + /// + /// + /// + /// + public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, RoutingOutputPort outputPort, eRoutingSignalType signalType) { Destination = destination; InputPort = inputPort; Source = source; + OutputPort = outputPort; SignalType = signalType; Routes = new List(); } @@ -72,7 +90,7 @@ namespace PepperDash.Essentials.Core { foreach (var route in Routes) { - Debug.LogMessage(LogEventLevel.Verbose, "ExecuteRoutes: {0}", null, route.ToString()); + Debug.LogVerbose("ExecuteRoutes: {0}", route.ToString()); if (route.SwitchingDevice is IRoutingSinkWithSwitching sink) { @@ -86,7 +104,7 @@ namespace PepperDash.Essentials.Core route.OutputPort.InUseTracker.AddUser(Destination, "destination-" + SignalType); - Debug.LogMessage(LogEventLevel.Verbose, "Output port {0} routing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); + Debug.LogVerbose("Output port {0} routing. Count={1}", route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); } } } @@ -112,6 +130,7 @@ namespace PepperDash.Essentials.Core catch (Exception e) { Debug.LogError("Error executing switch: {exception}", e.Message); + Debug.LogDebug(e, "Stack Trace: "); } } @@ -123,11 +142,11 @@ namespace PepperDash.Essentials.Core if (route.OutputPort.InUseTracker != null) { route.OutputPort.InUseTracker.RemoveUser(Destination, "destination-" + SignalType); - Debug.LogMessage(LogEventLevel.Verbose, "Port {0} releasing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); + Debug.LogVerbose("Port {0} releasing. Count={1}", route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); } else { - Debug.LogMessage(LogEventLevel.Error, "InUseTracker is null for OutputPort {0}", null, route.OutputPort.Key); + Debug.LogVerbose("InUseTracker is null for OutputPort {0}", route.OutputPort.Key); } } } diff --git a/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs b/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs index 5d8147cb..615b24c3 100644 --- a/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs +++ b/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs @@ -51,20 +51,25 @@ namespace PepperDash.Essentials.Core t.Destination == descriptor.Destination && t.SignalType == descriptor.SignalType && ((t.InputPort == null && descriptor.InputPort == null) || - (t.InputPort != null && descriptor.InputPort != null && t.InputPort.Key == descriptor.InputPort.Key))); + (t.InputPort != null && descriptor.InputPort != null && t.InputPort.Key == descriptor.InputPort.Key)) && + ((t.OutputPort == null && descriptor.OutputPort == null) || + (t.OutputPort != null && descriptor.OutputPort != null && t.OutputPort.Key == descriptor.OutputPort.Key))); if (existingRoute != null) { - Debug.LogMessage(LogEventLevel.Information, descriptor.Destination, - "Route from {0} to {1}:{2} ({3}) already exists in this collection", + Debug.LogInformation(descriptor.Destination, + "Route from {source}:{outputPort} to {destination}:{inputPort} ({signalType}) already exists in this collection", descriptor?.Source?.Key, + descriptor?.OutputPort?.Key ?? "auto", descriptor?.Destination?.Key, descriptor?.InputPort?.Key ?? "auto", - descriptor?.SignalType); + descriptor?.SignalType + ); return; } - Debug.LogMessage(LogEventLevel.Verbose, "Adding route descriptor: {0} -> {1}:{2} ({3})", + Debug.LogVerbose("Adding route descriptor: {source}:{outputPort} -> {destination}:{inputPort} ({signalType})", descriptor?.Source?.Key, + descriptor?.OutputPort?.Key ?? "auto", descriptor?.Destination?.Key, descriptor?.InputPort?.Key ?? "auto", descriptor?.SignalType); diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index dfed5fdd..2a7a48c5 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -257,7 +257,7 @@ namespace PepperDash.Essentials.Core.Web } /// - /// Print the available pahts + /// Print the available paths /// /// /// http(s)://{ipaddress}/cws/{basePath} diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index 815aaa41..03c01a0c 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.IO.Compression; using System.Linq; using System.Reflection; @@ -462,7 +461,7 @@ namespace PepperDash.Essentials { try { - if (args.Contains("?")) + if (!string.IsNullOrEmpty(args) && args.Contains("?")) { CrestronConsole.ConsoleCommandResponse("Usage: listtielines [signaltype]\r\n"); CrestronConsole.ConsoleCommandResponse("Signal types: Audio, Video, SecondaryAudio, AudioVideo, UsbInput, UsbOutput\r\n"); @@ -508,7 +507,7 @@ namespace PepperDash.Essentials { try { - if (args.Contains("?")) + if (!string.IsNullOrEmpty(args) && args.Contains("?")) { CrestronConsole.ConsoleCommandResponse("Usage: visualizeroutes [signaltype] [-s source] [-d destination]\r\n"); CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n"); @@ -557,7 +556,7 @@ namespace PepperDash.Essentials { try { - if (args.Contains("?")) + if (!string.IsNullOrEmpty(args) && args.Contains("?")) { CrestronConsole.ConsoleCommandResponse("Usage: visualizecurrentroutes [signaltype] [-s source] [-d destination]\r\n"); CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n"); From 4f86009209e425c67c2172092ce3d5c50a2c994b Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Fri, 17 Apr 2026 10:56:14 -0500 Subject: [PATCH 16/46] chore: remove unused using statements --- .../WebApiHandlers/DeleteAllUiClientsHandler.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs b/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs index c08f9af1..fd5f9844 100644 --- a/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs +++ b/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs @@ -1,10 +1,8 @@ using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; -using PepperDash.Core; using PepperDash.Core.Web.RequestHandlers; -using PepperDash.Essentials.Core.Web; using PepperDash.Essentials.WebSocketServer; -using Serilog.Events; + namespace PepperDash.Essentials.WebApiHandlers { From 18088d37a1e232b8de13e33d9edebec5d80042d5 Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Fri, 17 Apr 2026 10:56:51 -0500 Subject: [PATCH 17/46] feat: handle timers correctly and migrate to native Timer instead of CTimer --- .../Routing/RoutingFeedbackManager.cs | 152 ++++++++++++------ 1 file changed, 107 insertions(+), 45 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs index 61dfcbab..16822ff2 100644 --- a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs +++ b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using Crestron.SimplSharp; +using System.Timers; using PepperDash.Core; using PepperDash.Essentials.Core.Config; @@ -21,7 +21,12 @@ namespace PepperDash.Essentials.Core.Routing /// /// Debounce timers for each sink device to prevent rapid successive updates /// - private readonly Dictionary updateTimers = new Dictionary(); + private readonly Dictionary updateTimers = new Dictionary(); + + /// + /// Lock object protecting all access to . + /// + private readonly object _timerLock = new object(); /// /// Debounce delay in milliseconds @@ -50,7 +55,6 @@ namespace PepperDash.Essentials.Core.Routing midpointToSinksMap = new Dictionary>(); var sinks = DeviceManager.AllDevices.OfType(); - var midpoints = DeviceManager.AllDevices.OfType(); foreach (var sink in sinks) { @@ -211,9 +215,46 @@ namespace PepperDash.Essentials.Core.Routing } } + /// + /// Removes a sink from every midpoint set in the map and re-adds it based on its + /// current input port. Call this whenever a sink's selected input changes so that + /// HandleMidpointUpdate always sees an up-to-date downstream set. + /// + private void RebuildMapForSink(IRoutingSinkWithSwitchingWithInputPort sink) + { + if (midpointToSinksMap == null) + return; + + // Remove this sink from all existing midpoint sets + foreach (var set in midpointToSinksMap.Values) + set.Remove(sink.Key); + + // Drop any midpoint entries that are now empty + var emptyKeys = midpointToSinksMap + .Where(kvp => kvp.Value.Count == 0) + .Select(kvp => kvp.Key) + .ToList(); + foreach (var k in emptyKeys) + midpointToSinksMap.Remove(k); + + // Re-add the sink under every midpoint that is upstream of its new input + if (sink.CurrentInputPort == null) + return; + + var upstreamMidpoints = GetUpstreamMidpoints(sink); + foreach (var midpointKey in upstreamMidpoints) + { + if (!midpointToSinksMap.ContainsKey(midpointKey)) + midpointToSinksMap[midpointKey] = new HashSet(); + + midpointToSinksMap[midpointKey].Add(sink.Key); + } + } + /// /// Handles the InputChanged event from a sink device. - /// Triggers an update for the specific sink device. + /// Updates the midpoint-to-sink map for the new input path, then triggers + /// a source-info update for the sink. /// /// The sink device that reported an input change. /// The new input port selected on the sink device. @@ -224,6 +265,10 @@ namespace PepperDash.Essentials.Core.Routing { try { + // Keep the map current so HandleMidpointUpdate can find this sink + if (sender is IRoutingSinkWithSwitchingWithInputPort sinkWithInputPort) + RebuildMapForSink(sinkWithInputPort); + UpdateDestination(sender, currentInputPort); } catch (Exception ex) @@ -255,15 +300,13 @@ namespace PepperDash.Essentials.Core.Routing var key = destination.Key; - // Cancel existing timer for this sink - if (updateTimers.TryGetValue(key, out var existingTimer)) - { - existingTimer.Stop(); - existingTimer.Dispose(); - } + // Cancel and replace any existing timer under the lock so no callback + // can race with us while we swap the entry. + Timer timerToDispose = null; + Timer newTimer = null; - // Start new debounced timer - updateTimers[key] = new CTimer(_ => + newTimer = new Timer(DEBOUNCE_MS) { AutoReset = false }; + newTimer.Elapsed += (s, e) => { try { @@ -281,13 +324,36 @@ namespace PepperDash.Essentials.Core.Routing } finally { - if (updateTimers.ContainsKey(key)) + // Remove the entry first so a concurrent UpdateDestination call + // cannot re-dispose whatever timer we're about to dispose. + Timer selfTimer = null; + lock (_timerLock) { - updateTimers[key]?.Dispose(); - updateTimers.Remove(key); + if (updateTimers.TryGetValue(key, out var current) && ReferenceEquals(current, newTimer)) + { + selfTimer = current; + updateTimers.Remove(key); + } } + selfTimer?.Dispose(); } - }, null, DEBOUNCE_MS); + }; + + lock (_timerLock) + { + if (updateTimers.TryGetValue(key, out var existingTimer)) + timerToDispose = existingTimer; + + updateTimers[key] = newTimer; + } + + // Dispose the old timer outside the lock to avoid holding the lock during disposal. + // Dispose implicitly stops the timer, preventing its Elapsed event from firing. + timerToDispose?.Dispose(); + + // Start after the lock is released so the Elapsed callback cannot deadlock + // trying to acquire _timerLock while we still hold it. + newTimer.Start(); } /// @@ -384,7 +450,8 @@ namespace PepperDash.Essentials.Core.Routing } catch (Exception ex) { - Debug.LogMessage(ex, "Error getting sourceTieLine: {Exception}", this, ex); + Debug.LogError(this, "Error getting sourceTieLine: {message}", ex.Message); + Debug.LogDebug(ex, "StackTrace: "); return; } @@ -408,6 +475,11 @@ namespace PepperDash.Essentials.Core.Routing return roomDefaultDisplay.DefaultDisplay.Key == destination.Key; } + if (ConfigReader.ConfigObject.GetDestinationListForKey(r.DestinationListKey)?.FirstOrDefault(d => d.Value.SinkKey == destination.Key) != null) + { + return true; + } + return false; } ); @@ -429,10 +501,8 @@ namespace PepperDash.Essentials.Core.Routing if (sourceList == null) { - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, + Debug.LogDebug(this, "No source list found for source list key {key}. Unable to find source for tieLine {sourceTieLine}", - this, room.SourceListKey, sourceTieLine ); @@ -461,10 +531,8 @@ namespace PepperDash.Essentials.Core.Routing if (source == null) { - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, + Debug.LogDebug(this, "No source found for device {key}. Creating transient source for {destination}", - this, sourceTieLine.SourcePort.ParentDevice.Key, destination ); @@ -498,10 +566,8 @@ namespace PepperDash.Essentials.Core.Routing { if (!(tieLine.DestinationPort.ParentDevice is IRoutingInputs sink)) { - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, + Debug.LogDebug(this, "TieLine destination {device} is not IRoutingInputs", - this, tieLine.DestinationPort.ParentDevice.Key ); return null; @@ -540,17 +606,21 @@ namespace PepperDash.Essentials.Core.Routing if (route != null && route.Routes != null && route.Routes.Count > 0) { - // Found a valid route - return the source TieLine + // Routes[0] is the hop nearest the source: its InputPort is the + // port on the first switching device that receives the signal from + // the source side. The TieLine whose DestinationPort matches that + // port is the exact tie that was traversed, giving us the precise + // source output port via SourcePort — regardless of how many output + // ports the source device has. + var firstHop = route.Routes[0]; var sourceTieLine = TieLineCollection.Default.FirstOrDefault(tl => - tl.SourcePort.ParentDevice.Key == source.Key && - tl.Type.HasFlag(signalType)); + tl.DestinationPort.Key == firstHop.InputPort.Key && + tl.DestinationPort.ParentDevice.Key == firstHop.InputPort.ParentDevice.Key); if (sourceTieLine != null) { - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, - "Found route from {source} to {sink} with {count} hops", - this, + Debug.LogDebug(this, + "Found route from {source} to {sink} with {count} hops", source.Key, sink.Key, route.Routes.Count @@ -561,22 +631,14 @@ namespace PepperDash.Essentials.Core.Routing } } - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, - "No route found to any source from {sink}", - this, - sink.Key - ); + Debug.LogDebug(this, "No route found to any source from {sink}", sink.Key); return null; } catch (Exception ex) { - Debug.LogMessage( - ex, - "Error getting root tieLine: {Exception}", - this, - ex - ); + Debug.LogError(this, "Error getting root tieLine: {message}", ex.Message); + Debug.LogDebug(ex, "StackTrace: "); + return null; } } From 0a991cffb0a02bb89494e944cd2c80d203a9466b Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Fri, 17 Apr 2026 10:57:36 -0500 Subject: [PATCH 18/46] docs: remove commented out code and associated XML comments --- src/PepperDash.Core/Device.cs | 70 +++++++++++++++-------------------- 1 file changed, 30 insertions(+), 40 deletions(-) diff --git a/src/PepperDash.Core/Device.cs b/src/PepperDash.Core/Device.cs index 7124550e..58b5fe3e 100644 --- a/src/PepperDash.Core/Device.cs +++ b/src/PepperDash.Core/Device.cs @@ -5,9 +5,9 @@ using Serilog.Events; namespace PepperDash.Core { //********************************************************************************************************* - /// - /// Represents a Device - /// + /// + /// Represents a Device + /// public class Device : IKeyName { @@ -15,25 +15,15 @@ namespace PepperDash.Core /// Unique Key /// public string Key { get; protected set; } - /// - /// Gets or sets the Name - /// + /// + /// Gets or sets the Name + /// public string Name { get; protected set; } /// /// /// public bool Enabled { get; protected set; } - /// - /// A place to store reference to the original config object, if any. These values should - /// NOT be used as properties on the device as they are all publicly-settable values. - /// - //public DeviceConfig Config { get; private set; } - /// - /// Helper method to check if Config exists - /// - //public bool HasConfig { get { return Config != null; } } - List _PreActivationActions; List _PostActivationActions; @@ -86,9 +76,9 @@ namespace PepperDash.Core /// Adds a post activation action /// /// - /// - /// AddPostActivationAction method - /// + /// + /// AddPostActivationAction method + /// public void AddPostActivationAction(Action act) { if (_PostActivationActions == null) @@ -96,9 +86,9 @@ namespace PepperDash.Core _PostActivationActions.Add(act); } - /// - /// PreActivate method - /// + /// + /// PreActivate method + /// public void PreActivate() { if (_PreActivationActions != null) @@ -115,9 +105,9 @@ namespace PepperDash.Core }); } - /// - /// Activate method - /// + /// + /// Activate method + /// public bool Activate() { //if (_PreActivationActions != null) @@ -128,9 +118,9 @@ namespace PepperDash.Core return result; } - /// - /// PostActivate method - /// + /// + /// PostActivate method + /// public void PostActivate() { if (_PostActivationActions != null) @@ -153,9 +143,9 @@ namespace PepperDash.Core /// do not need to call base.CustomActivate() /// /// true if device activated successfully. - /// - /// CustomActivate method - /// + /// + /// CustomActivate method + /// public virtual bool CustomActivate() { return true; } /// @@ -182,15 +172,15 @@ namespace PepperDash.Core if (o is bool && !(bool)o) a(); } - /// - /// Returns a string representation of the object, including its key and name. - /// - /// The returned string is formatted as "{Key} - {Name}". If the Name property is - /// null or empty, "---" is used in place of the name. - /// A string that represents the object, containing the key and name in the format "{Key} - {Name}". - /// - /// ToString method - /// + /// + /// Returns a string representation of the object, including its key and name. + /// + /// The returned string is formatted as "{Key} - {Name}". If the Name property is + /// null or empty, "---" is used in place of the name. + /// A string that represents the object, containing the key and name in the format "{Key} - {Name}". + /// + /// ToString method + /// public override string ToString() { return string.Format("{0} - {1}", Key, string.IsNullOrEmpty(Name) ? "---" : Name); From ee240ca378d19ac002bc2ee38d3332883280d814 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 30 Apr 2026 14:22:53 -0600 Subject: [PATCH 19/46] feat: add ServeDebugAppRequestHandler for serving debug app assets and improve error handling in LoginRequestHandler Co-authored-by: Copilot --- src/PepperDash.Core/Web/WebApiServer.cs | 67 +++-- .../Web/EssentialsWebApi.cs | 10 +- .../Web/EssentialsWebApiHelpers.cs | 6 +- .../RequestHandlers/LoginRequestHandler.cs | 9 +- .../ServeDebugAppRequestHandler.cs | 230 ++++++++++++++++++ src/PepperDash.Essentials/ControlSystem.cs | 2 - 6 files changed, 288 insertions(+), 36 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs diff --git a/src/PepperDash.Core/Web/WebApiServer.cs b/src/PepperDash.Core/Web/WebApiServer.cs index cd113c15..d4b7890f 100644 --- a/src/PepperDash.Core/Web/WebApiServer.cs +++ b/src/PepperDash.Core/Web/WebApiServer.cs @@ -5,6 +5,7 @@ using Crestron.SimplSharp; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using PepperDash.Core.Logging; using PepperDash.Core.Web.RequestHandlers; namespace PepperDash.Core.Web @@ -25,24 +26,24 @@ namespace PepperDash.Core.Web private readonly CCriticalSection _serverLock = new CCriticalSection(); private HttpCwsServer _server; - /// - /// Gets or sets the Key - /// + /// + /// Gets or sets the Key + /// public string Key { get; private set; } - /// - /// Gets or sets the Name - /// + /// + /// Gets or sets the Name + /// public string Name { get; private set; } - /// - /// Gets or sets the BasePath - /// + /// + /// Gets or sets the BasePath + /// public string BasePath { get; private set; } - /// - /// Gets or sets the IsRegistered - /// + /// + /// Gets or sets the IsRegistered + /// public bool IsRegistered { get; private set; } /// @@ -91,7 +92,7 @@ namespace PepperDash.Core.Web /// /// /// - public WebApiServer(string key, string name, string basePath) + public WebApiServer(string key, string name, string basePath) { Key = key; Name = string.IsNullOrEmpty(name) ? DefaultName : name; @@ -139,9 +140,9 @@ namespace PepperDash.Core.Web Start(); } - /// - /// Initialize method - /// + /// + /// Initialize method + /// public void Initialize(string key, string basePath) { Key = key; @@ -167,9 +168,9 @@ namespace PepperDash.Core.Web /// Removes a route from CWS /// /// - /// - /// RemoveRoute method - /// + /// + /// RemoveRoute method + /// public void RemoveRoute(HttpCwsRoute route) { if (route == null) @@ -181,9 +182,25 @@ namespace PepperDash.Core.Web _server.Routes.Remove(route); } - /// - /// GetRouteCollection method - /// + /// + /// Sets the fallback request handler that is invoked when no registered route + /// matches an incoming request. Must be called before . + /// + /// The handler to use as the server-level fallback. + public void SetFallbackHandler(IHttpCwsHandler handler) + { + if (handler == null) + { + this.LogWarning("SetFallbackHandler: handler parameter is null, ignoring"); + return; + } + + _server.HttpRequestHandler = handler; + } + + /// + /// GetRouteCollection method + /// public HttpCwsRouteCollection GetRouteCollection() { return _server.Routes; @@ -227,9 +244,9 @@ namespace PepperDash.Core.Web } } - /// - /// Stop method - /// + /// + /// Stop method + /// public void Stop() { try diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index dfed5fdd..071e18f1 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -17,6 +17,9 @@ namespace PepperDash.Essentials.Core.Web { private readonly WebApiServer _server; + private readonly WebApiServer _debugServer; + + /// /// http(s)://{ipaddress}/cws/{basePath} /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} @@ -70,6 +73,9 @@ namespace PepperDash.Essentials.Core.Web _server = new WebApiServer(Key, Name, BasePath); + _debugServer = new WebApiServer($"{key}-debug-app", $"{name} Debug App", "/debug"); + _debugServer.SetFallbackHandler(new ServeDebugAppRequestHandler()); + SetupRoutes(); } @@ -242,6 +248,7 @@ namespace PepperDash.Essentials.Core.Web Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series"); _server.Start(); + _debugServer.Start(); GetPaths(); @@ -252,7 +259,8 @@ namespace PepperDash.Essentials.Core.Web Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server"); _server.Start(); - + _debugServer.Start(); + GetPaths(); } diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs index 397278f5..c966ea59 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs @@ -36,7 +36,7 @@ namespace PepperDash.Essentials.Core.Web }; } - + /// /// MapToDeviceListObject method @@ -119,9 +119,5 @@ namespace PepperDash.Essentials.Core.Web }; } - internal static bool IsAuthenticated(HttpCwsContext context) - { - throw new NotImplementedException(); - } } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index 67aded59..8d724f11 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -71,14 +71,17 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers { context.Response.StatusCode = 401; context.Response.StatusDescription = "Bad Request"; + context.Response.StatusDescription = "Unauthorized"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(JsonConvert.SerializeObject(new { Error = "Unauthorized" }, Formatting.Indented), false); context.Response.End(); - return; } if (!token.Valid) - { - context.Response.StatusCode = 401; + { + context.Response.StatusCode = 401; context.Response.StatusDescription = "Unauthorized"; context.Response.End(); diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs new file mode 100644 index 00000000..3e48859e --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronIO; +using Crestron.SimplSharp.WebScripting; +using PepperDash.Core; +using PepperDash.Core.Web.RequestHandlers; +using Serilog.Events; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Serves the React debug app from the processor's /HTML/debug folder. + /// The root route (debug) and all sub-paths (debug/{*filePath}) are handled here. + /// Text assets are sent as UTF-8 strings; binary assets are written to the response + /// OutputStream. Any sub-path that does not match a real file falls back to + /// index.html so that client-side (React Router) routing continues to work. + /// + public class ServeDebugAppRequestHandler : WebApiBaseRequestHandler + { + private static readonly Dictionary MimeTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { ".html", "text/html; charset=utf-8" }, + { ".htm", "text/html; charset=utf-8" }, + { ".js", "application/javascript" }, + { ".mjs", "application/javascript" }, + { ".jsx", "application/javascript" }, + { ".css", "text/css" }, + { ".json", "application/json" }, + { ".map", "application/json" }, + { ".svg", "image/svg+xml" }, + { ".ico", "image/x-icon" }, + { ".png", "image/png" }, + { ".jpg", "image/jpeg" }, + { ".jpeg", "image/jpeg" }, + { ".gif", "image/gif" }, + { ".woff", "font/woff" }, + { ".woff2","font/woff2" }, + { ".ttf", "font/ttf" }, + { ".eot", "application/vnd.ms-fontobject" }, + }; + + private static readonly HashSet TextExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".html", ".htm", ".js", ".mjs", ".jsx", ".css", ".json", ".map", ".svg", ".txt", ".xml" + }; + + /// + /// Constructor. CORS is enabled so browser dev-tools requests succeed. + /// + public ServeDebugAppRequestHandler() : base(true) { } + + /// + /// Handles GET requests for the debug app and its static assets. + /// + protected override void HandleGet(HttpCwsContext context) + { + // When acting as the server-level fallback handler, only handle + // requests that are actually for the /debug path; defer everything + // else to the base class (which returns 501 Not Implemented). + var rawUrl = context.Request.RawUrl ?? string.Empty; + if (rawUrl.IndexOf("/debug", StringComparison.OrdinalIgnoreCase) < 0) + { + base.HandleGet(context); + return; + } + + try + { + var htmlDebugPath = GetHtmlDebugPath(); + if (htmlDebugPath == null) + { + SendResponse(context, 500, "Internal Server Error"); + return; + } + + var requestedPath = GetRequestedFilePath(context); + + // Paths with no file extension are SPA client-side routes — serve index.html + string candidate; + if (string.IsNullOrEmpty(requestedPath) || !System.IO.Path.HasExtension(requestedPath)) + { + candidate = System.IO.Path.Combine(htmlDebugPath, "index.html"); + } + else + { + var relativePart = requestedPath.Replace('/', System.IO.Path.DirectorySeparatorChar); + candidate = System.IO.Path.Combine(htmlDebugPath, relativePart); + } + + // Resolve to an absolute path and guard against path-traversal attacks + var resolvedCandidate = System.IO.Path.GetFullPath(candidate); + var resolvedBase = System.IO.Path.GetFullPath(htmlDebugPath) + + System.IO.Path.DirectorySeparatorChar; + + if (!resolvedCandidate.StartsWith(resolvedBase, StringComparison.OrdinalIgnoreCase)) + { + SendResponse(context, 403, "Forbidden"); + return; + } + + // Missing static asset → fall back to index.html (SPA deep-link support) + if (!File.Exists(resolvedCandidate) && System.IO.Path.HasExtension(requestedPath ?? string.Empty)) + { + resolvedCandidate = System.IO.Path.Combine(htmlDebugPath, "index.html"); + Debug.LogMessage(LogEventLevel.Debug, + "ServeDebugAppRequestHandler: '{requestedPath:l}' not found, falling back to index.html", + requestedPath); + } + + if (!File.Exists(resolvedCandidate)) + { + SendResponse(context, 404, "Not Found"); + return; + } + + var ext = System.IO.Path.GetExtension(resolvedCandidate); + var contentType = MimeTypes.TryGetValue(ext, out var mime) ? mime : "application/octet-stream"; + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = contentType; + + if (TextExtensions.Contains(ext)) + { + string content; + using (var reader = new StreamReader(resolvedCandidate)) + content = reader.ReadToEnd(); + + context.Response.ContentEncoding = Encoding.UTF8; + context.Response.Write(content, false); + } + else + { + var bytes = System.IO.File.ReadAllBytes(resolvedCandidate); + context.Response.OutputStream.Write(bytes, 0, bytes.Length); + } + + context.Response.End(); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Error, + "ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}': {ex}", + context.Request.RawUrl, ex.Message); + try { SendResponse(context, 500, "Internal Server Error"); } catch { /* best-effort */ } + } + } + + /// + /// Resolves the absolute path of the /HTML/debug folder on the processor. + /// + /// + /// On a 4-series appliance, Global.FilePathPrefix is + /// {root}/user/programX/, so walking up two parents gives the + /// processor root that contains the html folder. + /// On Virtual Control, Global.FilePathPrefix is {root}/User/, + /// so only one parent hop is needed. + /// + private static string GetHtmlDebugPath() + { + try + { + var separators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; + var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(separators)); + + DirectoryInfo rootDir; + if (CrestronEnvironment.DevicePlatform == eDevicePlatform.Server) + { + // Virtual Control: {root}/User/ → one parent up = {root} + rootDir = programDir.Parent; + } + else + { + // 4-series appliance: {root}/user/programX/ → two parents up = {root} + rootDir = programDir.Parent?.Parent; + } + + if (rootDir == null) + { + Debug.LogMessage(LogEventLevel.Error, + "ServeDebugAppRequestHandler: Cannot resolve HTML root from FilePathPrefix '{prefix:l}'", + Global.FilePathPrefix); + return null; + } + + return System.IO.Path.Combine(rootDir.FullName, "html", "debug"); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Error, + "ServeDebugAppRequestHandler: Error resolving HTML debug path: {ex}", ex.Message); + return null; + } + } + + /// + /// Extracts the file sub-path from the request by parsing RawUrl. + /// Returns an empty string when the URL ends at /debug (root hit). + /// + private static string GetRequestedFilePath(HttpCwsContext context) + { + var rawUrl = context.Request.RawUrl ?? string.Empty; + + // Locate the /debug segment in the URL + const string debugToken = "/debug"; + var idx = rawUrl.IndexOf(debugToken, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + return string.Empty; + + var afterDebug = rawUrl.Substring(idx + debugToken.Length); + + // Strip query string + var qIdx = afterDebug.IndexOf('?'); + if (qIdx >= 0) + afterDebug = afterDebug.Substring(0, qIdx); + + // Strip leading slash to get a relative file path + return afterDebug.TrimStart('/'); + } + + private static void SendResponse(HttpCwsContext context, int statusCode, string statusDescription) + { + context.Response.StatusCode = statusCode; + context.Response.StatusDescription = statusDescription; + context.Response.End(); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index 815aaa41..790fd013 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO.Compression; using System.Linq; using System.Reflection; using Crestron.SimplSharp; From b1a575e4d2dc6063e58900294af3d3ecbc399f1a Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 1 May 2026 14:43:48 -0600 Subject: [PATCH 20/46] feat: enhance URL generation in DebugWebsocketSink to support dual-stack environments --- src/PepperDash.Core/Logging/DebugWebsocketSink.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 12b3d90a..b94d9b12 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -46,8 +46,15 @@ namespace PepperDash.Core { get { - if (_httpsServer == null) return ""; - return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{_httpsServer.WebSocketServices[_path].Path}"; + if (_httpsServer == null || !_httpsServer.IsListening) return ""; + var service = _httpsServer.WebSocketServices[_path]; + if (service == null) return ""; + + // Use CSLAN IP if available, otherwise fallback to primary IP. This ensures we provide a reachable URL in dual-stack environments. + if (!string.IsNullOrEmpty(CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1))) + return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1)}:{_httpsServer.Port}{service.Path}"; + else + return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{service.Path}"; } } From 3b57860123a2a7a6c1d4db21b91de381abd61aca Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 4 May 2026 09:43:06 -0600 Subject: [PATCH 21/46] fix: correct response status description in LoginRequestHandler and improve error logging in ServeDebugAppRequestHandler --- .../Web/RequestHandlers/LoginRequestHandler.cs | 5 ++--- .../ServeDebugAppRequestHandler.cs | 15 ++++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index 8d724f11..5a4b7df7 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -71,7 +71,6 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers { context.Response.StatusCode = 401; context.Response.StatusDescription = "Bad Request"; - context.Response.StatusDescription = "Unauthorized"; context.Response.ContentType = "application/json"; context.Response.ContentEncoding = System.Text.Encoding.UTF8; context.Response.Write(JsonConvert.SerializeObject(new { Error = "Unauthorized" }, Formatting.Indented), false); @@ -80,8 +79,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers } if (!token.Valid) - { - context.Response.StatusCode = 401; + { + context.Response.StatusCode = 401; context.Response.StatusDescription = "Unauthorized"; context.Response.End(); diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs index 3e48859e..2092c735 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs @@ -125,7 +125,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers if (TextExtensions.Contains(ext)) { string content; - using (var reader = new StreamReader(resolvedCandidate)) + using (var reader = new StreamReader(resolvedCandidate, Encoding.UTF8)) content = reader.ReadToEnd(); context.Response.ContentEncoding = Encoding.UTF8; @@ -141,10 +141,11 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers } catch (Exception ex) { - Debug.LogMessage(LogEventLevel.Error, - "ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}': {ex}", - context.Request.RawUrl, ex.Message); - try { SendResponse(context, 500, "Internal Server Error"); } catch { /* best-effort */ } + Debug.LogMessage(LogEventLevel.Error, ex, + "ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}'", + context.Request.RawUrl); + try { SendResponse(context, 500, "Internal Server Error"); } + catch { /* best-effort */ } } } @@ -189,8 +190,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers } catch (Exception ex) { - Debug.LogMessage(LogEventLevel.Error, - "ServeDebugAppRequestHandler: Error resolving HTML debug path: {ex}", ex.Message); + Debug.LogMessage(LogEventLevel.Error, ex, + "ServeDebugAppRequestHandler: Error resolving HTML debug path"); return null; } } From 447af3883cb497c9f46b61683305ffa05c35d408 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 15:48:12 +0000 Subject: [PATCH 22/46] fix: align GetHtmlDebugPath with AssetLoader two-hop path resolution Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/9d7d71b4-b083-412b-b7b2-3167561eeed3 Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com> --- .../ServeDebugAppRequestHandler.cs | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs index 2092c735..d8a296e5 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs @@ -153,11 +153,11 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// Resolves the absolute path of the /HTML/debug folder on the processor. /// /// - /// On a 4-series appliance, Global.FilePathPrefix is - /// {root}/user/programX/, so walking up two parents gives the - /// processor root that contains the html folder. - /// On Virtual Control, Global.FilePathPrefix is {root}/User/, - /// so only one parent hop is needed. + /// Global.FilePathPrefix is always {root}/user/programX/ (or + /// equivalent), so walking up two parents gives the processor root that + /// contains the html folder. This mirrors the two-hop strategy used + /// by AssetLoader.ExtractDevToolsZip so that serving and extraction + /// always resolve to the same directory. /// private static string GetHtmlDebugPath() { @@ -166,17 +166,10 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var separators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(separators)); - DirectoryInfo rootDir; - if (CrestronEnvironment.DevicePlatform == eDevicePlatform.Server) - { - // Virtual Control: {root}/User/ → one parent up = {root} - rootDir = programDir.Parent; - } - else - { - // 4-series appliance: {root}/user/programX/ → two parents up = {root} - rootDir = programDir.Parent?.Parent; - } + // Walk up two levels: {root}/user/programX/ → {root}/user/ → {root} + // This matches the path calculation used in AssetLoader.ExtractDevToolsZip. + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; if (rootDir == null) { From dd32f44e3df2cb4b048465581b03d230a0639d95 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 10:55:33 -0600 Subject: [PATCH 23/46] fix: clean up unused using directives and add firmware version check in ControlSystem Co-authored-by: Copilot --- src/PepperDash.Core/Logging/Debug.cs | 3 +-- .../Logging/DebugWebsocketSink.cs | 6 ++++-- src/PepperDash.Essentials/ControlSystem.cs | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/PepperDash.Core/Logging/Debug.cs b/src/PepperDash.Core/Logging/Debug.cs index 877d8997..97ac5890 100644 --- a/src/PepperDash.Core/Logging/Debug.cs +++ b/src/PepperDash.Core/Logging/Debug.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.Collections.Generic; -using System.Net; using System.Reflection; using System.Text.RegularExpressions; using Crestron.SimplSharp; diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index b94d9b12..fc3b0247 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -51,8 +51,10 @@ namespace PepperDash.Core if (service == null) return ""; // Use CSLAN IP if available, otherwise fallback to primary IP. This ensures we provide a reachable URL in dual-stack environments. - if (!string.IsNullOrEmpty(CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1))) - return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1)}:{_httpsServer.Port}{service.Path}"; + var cslanIp = CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1); + if (!string.IsNullOrEmpty(cslanIp) && cslanIp != "Invalid Value") + return $"wss://{cslanIp}:{_httpsServer.Port}{service.Path}"; else return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{service.Path}"; } diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index be020e2e..e6c61c60 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -27,6 +27,8 @@ namespace PepperDash.Essentials private CEvent _initializeEvent; private const long StartupTime = 500; + private const string minimumFirmwareVersion = "2.8006.00110"; + /// /// Initializes a new instance of the ControlSystem class /// @@ -46,6 +48,24 @@ namespace PepperDash.Essentials /// public override void InitializeSystem() { + + // Get FW version and stop if it's too low to run this version of Essentials. Must be greater than v2.8006.00110 + var fwVersion = InitialParametersClass.FirmwareVersion; + + Debug.LogInformation("Control System Hardware Version: {fwVersion}", fwVersion); + + // split the version into parts and compare against minimumFirmwareVersion + var versionParts = fwVersion.Split('.').Select(int.Parse).ToArray(); + var minParts = minimumFirmwareVersion.Split('.').Select(int.Parse).ToArray(); + if (versionParts.Length < minParts.Length + || versionParts[0] < minParts[0] + || (versionParts[0] == minParts[0] && versionParts[1] < minParts[1]) + || (versionParts[0] == minParts[0] && versionParts[1] == minParts[1] && versionParts[2] <= minParts[2])) + { + Debug.LogFatal("Firmware version {fwVersion} is too low to run this version of Essentials. Please upgrade to greater than v{minimumFirmwareVersion}.", fwVersion, minimumFirmwareVersion); + return; + } + // If the control system is a DMPS type, we need to wait to exit this method until all devices have had time to activate // to allow any HD-BaseT DM endpoints to register first. bool preventInitializationComplete = Global.ControlSystemIsDmpsType; From 507130c1ae08246e3bcb560601354ba35bcc130e Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 13:07:52 -0600 Subject: [PATCH 24/46] feat: refactor certificate generation to use BouncyCastle for improved security and flexibility Co-authored-by: Copilot --- .../Logging/DebugWebsocketSink.cs | 106 ++++++++++-------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index fc3b0247..e083da2d 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -8,10 +8,17 @@ using Crestron.SimplSharp; using WebSocketSharp; using System.Security.Authentication; using WebSocketSharp.Net; -using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.IO; +using Org.BouncyCastle.Asn1; using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; using Serilog.Formatting; using Serilog.Formatting.Json; @@ -114,51 +121,62 @@ namespace PepperDash.Core var subjectName = string.Format("CN={0}.{1}", hostName, domainName); var fqdn = string.Format("{0}.{1}", hostName, domainName); - using (var rsa = RSA.Create(2048)) + var random = new SecureRandom(); + + // Generate RSA 2048 key pair + var keyPairGenerator = new RsaKeyPairGenerator(); + keyPairGenerator.Init(new KeyGenerationParameters(random, 2048)); + var keyPair = keyPairGenerator.GenerateKeyPair(); + + // Build certificate + var certGenerator = new X509V3CertificateGenerator(); + certGenerator.SetSerialNumber(BigInteger.ValueOf(Math.Abs(DateTime.UtcNow.Ticks))); + certGenerator.SetIssuerDN(new X509Name(subjectName)); + certGenerator.SetSubjectDN(new X509Name(subjectName)); + certGenerator.SetNotBefore(DateTime.UtcNow); + certGenerator.SetNotAfter(DateTime.UtcNow.AddYears(2)); + certGenerator.SetPublicKey(keyPair.Public); + + // Extended Key Usage: server + client auth + certGenerator.AddExtension(X509Extensions.ExtendedKeyUsage, false, + new ExtendedKeyUsage(new[] { KeyPurposeID.id_kp_serverAuth, KeyPurposeID.id_kp_clientAuth })); + + // Subject Alternative Names: DNS + IP + System.Net.IPAddress parsedIp; + if (System.Net.IPAddress.TryParse(ipAddress, out parsedIp)) { - - var request = new CertificateRequest( - subjectName, - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - // Subject Key Identifier - request.CertificateExtensions.Add( - new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); - - // Extended Key Usage: server + client auth - request.CertificateExtensions.Add( - new X509EnhancedKeyUsageExtension( - new OidCollection - { - new Oid("1.3.6.1.5.5.7.3.1"), // id-kp-serverAuth - new Oid("1.3.6.1.5.5.7.3.2") // id-kp-clientAuth - }, - false)); - - // Subject Alternative Names: DNS + IP - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName(fqdn); - if (System.Net.IPAddress.TryParse(ipAddress, out var ip)) - sanBuilder.AddIpAddress(ip); - request.CertificateExtensions.Add(sanBuilder.Build()); - - var notBefore = DateTimeOffset.UtcNow; - var notAfter = notBefore.AddYears(2); - - using (var cert = request.CreateSelfSigned(notBefore, notAfter)) - { - - var separator = Path.DirectorySeparatorChar; - var outputPath = string.Format("{0}user{1}{2}.pfx", separator, separator, _certificateName); - - var pfxBytes = cert.Export(X509ContentType.Pfx, _certificatePassword); - File.WriteAllBytes(outputPath, pfxBytes); - - CrestronConsole.PrintLine(string.Format("CreateCert: Certificate written to {0}", outputPath)); - } + certGenerator.AddExtension(X509Extensions.SubjectAlternativeName, false, + new GeneralNames(new GeneralName[] { + new GeneralName(GeneralName.DnsName, fqdn), + new GeneralName(GeneralName.IPAddress, ipAddress) + })); } + else + { + certGenerator.AddExtension(X509Extensions.SubjectAlternativeName, false, + new GeneralNames(new GeneralName(GeneralName.DnsName, fqdn))); + } + + // Sign with SHA256withRSA + var signatureFactory = new Asn1SignatureFactory("SHA256WITHRSA", keyPair.Private, random); + var certificate = certGenerator.Generate(signatureFactory); + + // Export as PKCS12/PFX + var pkcs12Store = new Pkcs12StoreBuilder().Build(); + var certEntry = new X509CertificateEntry(certificate); + pkcs12Store.SetCertificateEntry(_certificateName, certEntry); + pkcs12Store.SetKeyEntry(_certificateName, new AsymmetricKeyEntry(keyPair.Private), new[] { certEntry }); + + var separator = Path.DirectorySeparatorChar; + var outputPath = string.Format("{0}user{1}{2}.pfx", separator, separator, _certificateName); + + using (var ms = new MemoryStream()) + { + pkcs12Store.Save(ms, _certificatePassword.ToCharArray(), random); + File.WriteAllBytes(outputPath, ms.ToArray()); + } + + CrestronConsole.PrintLine(string.Format("CreateCert: Certificate written to {0}", outputPath)); } catch (Exception ex) { From 1b32761e8e438a766562a6b37eff72950ebfda7e Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 13:45:17 -0600 Subject: [PATCH 25/46] fix: invert condition for system_url validation in MobileControlSystemController Co-authored-by: Copilot --- .../MobileControlSystemController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index 3adcaf87..3d466f5b 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -137,7 +137,7 @@ namespace PepperDash.Essentials "No system_url value defined in config. Checking for value from SIMPL Bridge." ); - if (!string.IsNullOrEmpty(SystemUrl)) + if (string.IsNullOrEmpty(SystemUrl)) { this.LogError( "No system_url value defined in config or SIMPL Bridge. Unable to connect to Mobile Control." From 89c23f5432005b38c9796f79a65c16a80932d5e1 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 09:01:27 -0600 Subject: [PATCH 26/46] feat: enhance certificate handling with BouncyCastle and remove firmware version checks Co-authored-by: Copilot --- .../Logging/DebugWebsocketSink.cs | 70 +++++++++++++++++-- src/PepperDash.Essentials/ControlSystem.cs | 28 ++++---- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index e083da2d..1f4dda7a 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -15,10 +15,12 @@ using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Math; using Org.BouncyCastle.Pkcs; using Org.BouncyCastle.Security; using Org.BouncyCastle.X509; +using System.Security.Cryptography; using Serilog.Formatting; using Serilog.Formatting.Json; @@ -172,7 +174,15 @@ namespace PepperDash.Core using (var ms = new MemoryStream()) { - pkcs12Store.Save(ms, _certificatePassword.ToCharArray(), random); + var passwordChars = _certificatePassword.ToCharArray(); + try + { + pkcs12Store.Save(ms, passwordChars, random); + } + finally + { + Array.Clear(passwordChars, 0, passwordChars.Length); + } File.WriteAllBytes(outputPath, ms.ToArray()); } @@ -215,23 +225,69 @@ namespace PepperDash.Core private static X509Certificate2 LoadOrRecreateCert(string certPath, string certPassword) { + if (!File.Exists(certPath)) + CreateCert(); + try { - // EphemeralKeySet is required on Linux/OpenSSL (Crestron 4-series) to avoid - // key-container persistence failures, and avoids the private key export restriction. - return new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet); + return LoadCertFromBouncyCastle(certPath, certPassword); } catch (Exception ex) { - // Cert is stale or was generated by an incompatible library (e.g. old BouncyCastle output). - // Delete it, regenerate with the BCL path, and retry once. + // Cert is corrupt or was written by an incompatible tool — delete and regenerate once. CrestronConsole.PrintLine(string.Format("SSL cert load failed ({0}); regenerating...", ex.Message)); try { File.Delete(certPath); } catch { } CreateCert(); - return new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet); + return LoadCertFromBouncyCastle(certPath, certPassword); } } + /// + /// Loads a PKCS#12 file written by BouncyCastle and returns an with + /// private key attached via . + /// Using BouncyCastle's own reader avoids the .NET/Mono PFX parser, which can reject + /// BouncyCastle-generated archives on the Crestron runtime. + /// + private static X509Certificate2 LoadCertFromBouncyCastle(string certPath, string certPassword) + { + var passwordChars = certPassword.ToCharArray(); + try + { + using (var stream = File.OpenRead(certPath)) + { + var store = new Pkcs12StoreBuilder().Build(); + store.Load(stream, passwordChars); + + foreach (string alias in store.Aliases) + { + if (!store.IsKeyEntry(alias)) continue; + + var keyEntry = store.GetKey(alias); + var certChain = store.GetCertificateChain(alias); + if (certChain == null || certChain.Length == 0) continue; + + // Build X509Certificate2 from raw DER — no PFX parsing by .NET needed. + var cert = new X509Certificate2(certChain[0].Certificate.GetEncoded()); + + // Attach the private key via RSACryptoServiceProvider (available on all target runtimes). + var rsaParams = DotNetUtilities.ToRSAParameters( + (RsaPrivateCrtKeyParameters)keyEntry.Key); + var rsa = new RSACryptoServiceProvider(); + rsa.ImportParameters(rsaParams); + cert.PrivateKey = rsa; + + return cert; + } + } + } + finally + { + Array.Clear(passwordChars, 0, passwordChars.Length); + } + + throw new InvalidOperationException("No key entry found in PKCS#12 store: " + certPath); + } + private void Start(int port, string certPath = "", string certPassword = "") { try diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index e6c61c60..d4a7545a 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -27,7 +27,7 @@ namespace PepperDash.Essentials private CEvent _initializeEvent; private const long StartupTime = 500; - private const string minimumFirmwareVersion = "2.8006.00110"; + // private const string minimumFirmwareVersion = "2.8006.00110"; /// /// Initializes a new instance of the ControlSystem class @@ -50,21 +50,21 @@ namespace PepperDash.Essentials { // Get FW version and stop if it's too low to run this version of Essentials. Must be greater than v2.8006.00110 - var fwVersion = InitialParametersClass.FirmwareVersion; + // var fwVersion = InitialParametersClass.FirmwareVersion; - Debug.LogInformation("Control System Hardware Version: {fwVersion}", fwVersion); + // Debug.LogInformation("Control System Hardware Version: {fwVersion}", fwVersion); - // split the version into parts and compare against minimumFirmwareVersion - var versionParts = fwVersion.Split('.').Select(int.Parse).ToArray(); - var minParts = minimumFirmwareVersion.Split('.').Select(int.Parse).ToArray(); - if (versionParts.Length < minParts.Length - || versionParts[0] < minParts[0] - || (versionParts[0] == minParts[0] && versionParts[1] < minParts[1]) - || (versionParts[0] == minParts[0] && versionParts[1] == minParts[1] && versionParts[2] <= minParts[2])) - { - Debug.LogFatal("Firmware version {fwVersion} is too low to run this version of Essentials. Please upgrade to greater than v{minimumFirmwareVersion}.", fwVersion, minimumFirmwareVersion); - return; - } + // // split the version into parts and compare against minimumFirmwareVersion + // var versionParts = fwVersion.Split('.').Select(int.Parse).ToArray(); + // var minParts = minimumFirmwareVersion.Split('.').Select(int.Parse).ToArray(); + // if (versionParts.Length < minParts.Length + // || versionParts[0] < minParts[0] + // || (versionParts[0] == minParts[0] && versionParts[1] < minParts[1]) + // || (versionParts[0] == minParts[0] && versionParts[1] == minParts[1] && versionParts[2] <= minParts[2])) + // { + // Debug.LogFatal("Firmware version {fwVersion} is too low to run this version of Essentials. Please upgrade to greater than v{minimumFirmwareVersion}.", fwVersion, minimumFirmwareVersion); + // return; + // } // If the control system is a DMPS type, we need to wait to exit this method until all devices have had time to activate // to allow any HD-BaseT DM endpoints to register first. From c52d585a0cc5b64484820589442ea9e9423272d3 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 10:44:54 -0600 Subject: [PATCH 27/46] feat: Allow WebAPI to load if no config file is present --- .../Web/EssentialsWebApi.cs | 6 ++++++ src/PepperDash.Essentials/ControlSystem.cs | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index 9e781883..375efdeb 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -20,6 +20,8 @@ namespace PepperDash.Essentials.Core.Web private readonly WebApiServer _debugServer; + + /// /// http(s)://{ipaddress}/cws/{basePath} /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} @@ -301,7 +303,11 @@ namespace PepperDash.Essentials.Core.Web { Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url); } + Debug.LogInformation(this, "Web API initialized and ready to accept requests"); + Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); + + Debug.LogMessage(LogEventLevel.Information, this, "Developer Tools Web App available at: https://{currentIp}/cws/debug", currentIp); } } } \ No newline at end of file diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index d4a7545a..fe7bf807 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -274,10 +274,9 @@ namespace PepperDash.Essentials PluginLoader.LoadPlugins(); Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config..."); - if (!ConfigReader.LoadConfig2()) + if (!ConfigReader.LoadConfig2() || ConfigReader.ConfigObject == null) { - Debug.LogMessage(LogEventLevel.Information, "Essentials Load complete with errors"); - return; + Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file. Please ensure a valid config file is present and restart the program."); } Load(); @@ -399,6 +398,12 @@ namespace PepperDash.Essentials new Core.Monitoring.SystemMonitorController("systemMonitor")); } + if (ConfigReader.ConfigObject is null) + { + Debug.LogMessage(LogEventLevel.Warning, "LoadDevices: ConfigObject is null. Cannot load devices."); + return; + } + foreach (var devConf in ConfigReader.ConfigObject.Devices) { IKeyed newDev = null; @@ -452,7 +457,7 @@ namespace PepperDash.Essentials var tlc = TieLineCollection.Default; - if (ConfigReader.ConfigObject.TieLines == null) + if (ConfigReader.ConfigObject?.TieLines == null) { return; } @@ -749,7 +754,7 @@ namespace PepperDash.Essentials /// public void LoadRooms() { - if (ConfigReader.ConfigObject.Rooms == null) + if (ConfigReader.ConfigObject?.Rooms == null) { Debug.LogMessage(LogEventLevel.Information, "Notice: Configuration contains no rooms - Is this intentional? This may be a valid configuration."); return; @@ -786,13 +791,13 @@ namespace PepperDash.Essentials /// void LoadLogoServer() { - if (ConfigReader.ConfigObject.Rooms == null) + if (ConfigReader.ConfigObject?.Rooms == null) { Debug.LogMessage(LogEventLevel.Information, "No rooms configured. Bypassing Logo server startup."); return; } - if ( + if (ConfigReader.ConfigObject?.Rooms == null || !ConfigReader.ConfigObject.Rooms.Any( CheckRoomConfig)) { From 2530003a58315fa325a83f1ced7eaf0522dcfa28 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 10:56:56 -0600 Subject: [PATCH 28/46] refactor: clean up whitespace and improve debug URL logging format --- src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index 375efdeb..3cdb8433 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -20,7 +20,7 @@ namespace PepperDash.Essentials.Core.Web private readonly WebApiServer _debugServer; - + /// /// http(s)://{ipaddress}/cws/{basePath} @@ -262,7 +262,7 @@ namespace PepperDash.Essentials.Core.Web _server.Start(); _debugServer.Start(); - + GetPaths(); } @@ -307,7 +307,11 @@ namespace PepperDash.Essentials.Core.Web Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); - Debug.LogMessage(LogEventLevel.Information, this, "Developer Tools Web App available at: https://{currentIp}/cws/debug", currentIp); + var debugAppUrl = CrestronEnvironment.DevicePlatform == eDevicePlatform.Server + ? $"https://{hostname}/VirtualControl/Rooms/{InitialParametersClass.RoomId}/cws/debug" + : $"https://{currentIp}/cws/debug"; + + Debug.LogMessage(LogEventLevel.Information, this, "Developer Tools Web App available at: {debugAppUrl:l}", debugAppUrl); } } } \ No newline at end of file From c050bb4eb342440c00a3acdecc42d5da496304d6 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 11:01:10 -0600 Subject: [PATCH 29/46] feat: disable key persistence for RSA in DebugWebsocketSink and simplify config loading warning message --- src/PepperDash.Core/Logging/DebugWebsocketSink.cs | 1 + src/PepperDash.Essentials/ControlSystem.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 1f4dda7a..8986fcb4 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -273,6 +273,7 @@ namespace PepperDash.Core var rsaParams = DotNetUtilities.ToRSAParameters( (RsaPrivateCrtKeyParameters)keyEntry.Key); var rsa = new RSACryptoServiceProvider(); + rsa.PersistKeyInCsp = false; rsa.ImportParameters(rsaParams); cert.PrivateKey = rsa; diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index fe7bf807..db057cbf 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -276,7 +276,7 @@ namespace PepperDash.Essentials Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config..."); if (!ConfigReader.LoadConfig2() || ConfigReader.ConfigObject == null) { - Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file. Please ensure a valid config file is present and restart the program."); + Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file."); } Load(); From 404728c708bb6f9750c1b30102cd3db57eea2fb8 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 12:07:29 -0600 Subject: [PATCH 30/46] feat: refactor portal info command to improve configuration handling and response messages --- src/PepperDash.Essentials/ControlSystem.cs | 38 ++++++++++++++-------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index db057cbf..5a0ba2af 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -130,14 +130,8 @@ namespace PepperDash.Essentials (ConfigReader.ConfigObject, Newtonsoft.Json.Formatting.Indented).Replace(Environment.NewLine, "\r\n")); }, "showconfig", "Shows the current running merged config", ConsoleAccessLevelEnum.AccessOperator); - CrestronConsole.AddNewConsoleCommand(s => - CrestronConsole.ConsoleCommandResponse( - "This system can be found at the following URLs:{2}" + - "System URL: {0}{2}" + - "Template URL: {1}{2}", - ConfigReader.ConfigObject.SystemUrl, - ConfigReader.ConfigObject.TemplateUrl, - CrestronEnvironment.NewLine), + CrestronConsole.AddNewConsoleCommand( + PrintPortalInfo, "portalinfo", "Shows portal URLS from configuration", ConsoleAccessLevelEnum.AccessOperator); @@ -160,6 +154,29 @@ namespace PepperDash.Essentials } } + private void PrintPortalInfo(string args) + { + if(ConfigReader.ConfigObject == null) + { + CrestronConsole.ConsoleCommandResponse("No configuration loaded. Cannot show portal URLs."); + return; + } + + if (string.IsNullOrEmpty(ConfigReader.ConfigObject.SystemUrl) && string.IsNullOrEmpty(ConfigReader.ConfigObject.TemplateUrl)) + { + CrestronConsole.ConsoleCommandResponse("No portal URLs defined in config."); + return; + } + + CrestronConsole.ConsoleCommandResponse( + "This system can be found at the following URLs:{2}" + + "System URL: {0}{2}" + + "Template URL: {1}{2}", + ConfigReader.ConfigObject?.SystemUrl, + ConfigReader.ConfigObject?.TemplateUrl, + CrestronEnvironment.NewLine); + } + /// /// DeterminePlatform method /// @@ -257,11 +274,6 @@ namespace PepperDash.Essentials PluginLoader.AddProgramAssemblies(); _ = new Core.DeviceFactory(); - // _ = new Devices.Common.DeviceFactory(); - // _ = new DeviceFactory(); - - // _ = new ProcessorExtensionDeviceFactory(); - // _ = new MobileControlFactory(); LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix); From 9fc866741e8c8ef6f34f1e1bb0139416f1337bed Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 8 May 2026 13:08:58 -0600 Subject: [PATCH 31/46] feat: mark deprecated classes and methods for future removal --- src/PepperDash.Core/Logging/DebugContext.cs | 1 + .../WebApi/Presets/WebApiPasscodeClient.cs | 211 +++++++++--------- .../Config/Essentials/ConfigUpdater.cs | 25 ++- .../Interfaces/ILogStrings.cs | 7 +- .../Interfaces/ILogStringsWithLevel.cs | 9 +- .../Routing/eRoutingSignalType.cs | 3 + .../Cameras/CameraVisca.cs | 2 + .../Messengers/DeviceStateMessageBase.cs | 2 + src/PepperDash.Essentials/ControlSystem.cs | 1 + 9 files changed, 137 insertions(+), 124 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugContext.cs b/src/PepperDash.Core/Logging/DebugContext.cs index e90ec86a..66e63634 100644 --- a/src/PepperDash.Core/Logging/DebugContext.cs +++ b/src/PepperDash.Core/Logging/DebugContext.cs @@ -11,6 +11,7 @@ namespace PepperDash.Core /// /// Represents a debugging context /// + [Obsolete("DebugContext is no longer supported and will be removed in a future release.")] public class DebugContext { /// diff --git a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs index f8c815fd..68bbe362 100644 --- a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs +++ b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs @@ -1,5 +1,5 @@ using System; -using Crestron.SimplSharp; // For Basic SIMPL# Classes +using Crestron.SimplSharp; // For Basic SIMPL# Classes using Crestron.SimplSharp.CrestronIO; using Crestron.SimplSharp.Net.Http; using Crestron.SimplSharp.Net.Https; @@ -10,24 +10,25 @@ using PepperDash.Core.JsonToSimpl; namespace PepperDash.Core.WebApi.Presets { - /// - /// Passcode client for the WebApi - /// + /// + /// Passcode client for the WebApi + /// + [Obsolete("WebApiPasscodeClient is no longer supported and will be removed in a future release.")] public class WebApiPasscodeClient : IKeyed { - /// - /// Notifies when user received - /// + /// + /// Notifies when user received + /// public event EventHandler UserReceived; - /// - /// Notifies when Preset received - /// + /// + /// Notifies when Preset received + /// public event EventHandler PresetReceived; - /// - /// Gets or sets the Key - /// + /// + /// Gets or sets the Key + /// public string Key { get; private set; } //string JsonMasterKey; @@ -54,13 +55,13 @@ namespace PepperDash.Core.WebApi.Presets { } - /// - /// Initializes the instance - /// - /// - /// - /// - /// + /// + /// Initializes the instance + /// + /// + /// + /// + /// public void Initialize(string key, string jsonMasterKey, string urlBase, string defaultPresetJsonFilePath) { Key = key; @@ -73,44 +74,44 @@ namespace PepperDash.Core.WebApi.Presets J2SMaster.Initialize(jsonMasterKey); } - /// - /// Gets the user for a passcode - /// - /// - /// - /// GetUserForPasscode method - /// + /// + /// Gets the user for a passcode + /// + /// + /// + /// GetUserForPasscode method + /// public void GetUserForPasscode(string passcode) { - // Bullshit duplicate code here... These two cases should be the same - // except for https/http and the certificate ignores - if (!UrlBase.StartsWith("https")) - return; - var req = new HttpsClientRequest(); - req.Url = new UrlParser(UrlBase + "/api/users/dopin"); - req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; - req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); - req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); - var jo = new JObject(); - jo.Add("pin", passcode); - req.ContentString = jo.ToString(); + // Bullshit duplicate code here... These two cases should be the same + // except for https/http and the certificate ignores + if (!UrlBase.StartsWith("https")) + return; + var req = new HttpsClientRequest(); + req.Url = new UrlParser(UrlBase + "/api/users/dopin"); + req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; + req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); + req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); + var jo = new JObject(); + jo.Add("pin", passcode); + req.ContentString = jo.ToString(); - var client = new HttpsClient(); - client.HostVerification = false; - client.PeerVerification = false; - var resp = client.Dispatch(req); - var handler = UserReceived; - if (resp.Code == 200) - { - //CrestronConsole.PrintLine("Received: {0}", resp.ContentString); - var user = JsonConvert.DeserializeObject(resp.ContentString); - CurrentUser = user; - if (handler != null) - UserReceived(this, new UserReceivedEventArgs(user, true)); - } - else - if (handler != null) - UserReceived(this, new UserReceivedEventArgs(null, false)); + var client = new HttpsClient(); + client.HostVerification = false; + client.PeerVerification = false; + var resp = client.Dispatch(req); + var handler = UserReceived; + if (resp.Code == 200) + { + //CrestronConsole.PrintLine("Received: {0}", resp.ContentString); + var user = JsonConvert.DeserializeObject(resp.ContentString); + CurrentUser = user; + if (handler != null) + UserReceived(this, new UserReceivedEventArgs(user, true)); + } + else + if (handler != null) + UserReceived(this, new UserReceivedEventArgs(null, false)); } /// @@ -118,9 +119,9 @@ namespace PepperDash.Core.WebApi.Presets /// /// /// - /// - /// GetPresetForThisUser method - /// + /// + /// GetPresetForThisUser method + /// public void GetPresetForThisUser(int roomTypeId, int presetNumber) { if (CurrentUser == null) @@ -136,57 +137,57 @@ namespace PepperDash.Core.WebApi.Presets PresetNumber = presetNumber }; - var handler = PresetReceived; + var handler = PresetReceived; try { - if (!UrlBase.StartsWith("https")) - return; - var req = new HttpsClientRequest(); - req.Url = new UrlParser(UrlBase + "/api/presets/userandroom"); - req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; - req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); - req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); - req.ContentString = JsonConvert.SerializeObject(msg); + if (!UrlBase.StartsWith("https")) + return; + var req = new HttpsClientRequest(); + req.Url = new UrlParser(UrlBase + "/api/presets/userandroom"); + req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; + req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); + req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); + req.ContentString = JsonConvert.SerializeObject(msg); - var client = new HttpsClient(); - client.HostVerification = false; - client.PeerVerification = false; + var client = new HttpsClient(); + client.HostVerification = false; + client.PeerVerification = false; - // ask for the preset - var resp = client.Dispatch(req); - if (resp.Code == 200) // got it - { - //Debug.Console(1, this, "Received: {0}", resp.ContentString); - var preset = JsonConvert.DeserializeObject(resp.ContentString); - CurrentPreset = preset; + // ask for the preset + var resp = client.Dispatch(req); + if (resp.Code == 200) // got it + { + //Debug.Console(1, this, "Received: {0}", resp.ContentString); + var preset = JsonConvert.DeserializeObject(resp.ContentString); + CurrentPreset = preset; - //if there's no preset data, load the template - if (preset.Data == null || preset.Data.Trim() == string.Empty || JObject.Parse(preset.Data).Count == 0) - { - //Debug.Console(1, this, "Loaded preset has no data. Loading default template."); - LoadDefaultPresetData(); - return; - } + //if there's no preset data, load the template + if (preset.Data == null || preset.Data.Trim() == string.Empty || JObject.Parse(preset.Data).Count == 0) + { + //Debug.Console(1, this, "Loaded preset has no data. Loading default template."); + LoadDefaultPresetData(); + return; + } - J2SMaster.LoadWithJson(preset.Data); - if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(preset, true)); - } - else // no existing preset - { - CurrentPreset = new Preset(); - LoadDefaultPresetData(); - if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(null, false)); - } + J2SMaster.LoadWithJson(preset.Data); + if (handler != null) + PresetReceived(this, new PresetReceivedEventArgs(preset, true)); + } + else // no existing preset + { + CurrentPreset = new Preset(); + LoadDefaultPresetData(); + if (handler != null) + PresetReceived(this, new PresetReceivedEventArgs(null, false)); + } } catch (HttpException e) { var resp = e.Response; Debug.Console(1, this, "No preset received (code {0}). Loading default template", resp.Code); LoadDefaultPresetData(); - if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(null, false)); + if (handler != null) + PresetReceived(this, new PresetReceivedEventArgs(null, false)); } } @@ -218,14 +219,14 @@ namespace PepperDash.Core.WebApi.Presets /// /// /// - /// - /// SavePresetForThisUser method - /// + /// + /// SavePresetForThisUser method + /// public void SavePresetForThisUser(int roomTypeId, int presetNumber) { if (CurrentPreset == null) LoadDefaultPresetData(); - //return; + //return; //// A new preset needs to have its numbers set //if (CurrentPreset.IsNewPreset) @@ -245,8 +246,8 @@ namespace PepperDash.Core.WebApi.Presets { CurrentPreset.Data = json; - if (!UrlBase.StartsWith("https")) - return; + if (!UrlBase.StartsWith("https")) + return; var req = new HttpsClientRequest(); req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; req.Url = new UrlParser(string.Format("{0}/api/presets/addorchange", UrlBase)); @@ -255,8 +256,8 @@ namespace PepperDash.Core.WebApi.Presets req.ContentString = JsonConvert.SerializeObject(CurrentPreset); var client = new HttpsClient(); - client.HostVerification = false; - client.PeerVerification = false; + client.HostVerification = false; + client.PeerVerification = false; try { var resp = client.Dispatch(req); diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs index 9df673bf..e940c573 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs @@ -19,6 +19,7 @@ namespace PepperDash.Essentials.Core.Config /// /// ConfigUpdater class /// + [Obsolete("ConfigUpdater is no longer supported and will be removed in a future release.")] public static class ConfigUpdater { /// @@ -81,7 +82,7 @@ namespace PepperDash.Essentials.Core.Config { var handler = ConfigStatusChanged; - if(handler != null) + if (handler != null) { handler(typeof(ConfigUpdater), new ConfigStatusEventArgs(status)); } @@ -89,7 +90,7 @@ namespace PepperDash.Essentials.Core.Config static void WriteConfigToFile(string configData) { - var filePath = Global.FilePathPrefix+ "configurationFile-updated.json"; + var filePath = Global.FilePathPrefix + "configurationFile-updated.json"; try { @@ -104,7 +105,7 @@ namespace PepperDash.Essentials.Core.Config Debug.LogMessage(LogEventLevel.Debug, "Error parsing new config: {0}", e); OnStatusUpdate(eUpdateStatus.UpdateFailed); - } + } } /// @@ -149,11 +150,11 @@ namespace PepperDash.Essentials.Core.Config // Directory exists, first clear any contents var archivedConfigFiles = ConfigReader.GetConfigFiles(archiveDirectoryPath + Global.DirectorySeparator + Global.ConfigFileName + ".bak"); - if(archivedConfigFiles != null || archivedConfigFiles.Length > 0) + if (archivedConfigFiles != null || archivedConfigFiles.Length > 0) { Debug.LogMessage(LogEventLevel.Information, "{0} Existing files found in archive folder. Deleting.", archivedConfigFiles.Length); - for (int i = 0; i < archivedConfigFiles.Length; i++ ) + for (int i = 0; i < archivedConfigFiles.Length; i++) { var file = archivedConfigFiles[i]; Debug.LogMessage(LogEventLevel.Information, "Deleting archived file: '{0}'", file.FullName); @@ -170,9 +171,9 @@ namespace PepperDash.Essentials.Core.Config // Moves the file and appends the .bak extension var fileDest = archiveDirectoryPath + "/" + file.Name + ".bak"; - if(!File.Exists(fileDest)) + if (!File.Exists(fileDest)) { - file.MoveTo(fileDest); + file.MoveTo(fileDest); } else Debug.LogMessage(LogEventLevel.Information, "Cannot move file to archive folder. Existing file already exists with same name: '{0}'", fileDest); @@ -207,15 +208,15 @@ namespace PepperDash.Essentials.Core.Config CrestronConsole.SendControlSystemCommand(string.Format("progreset -p:{0}", InitialParametersClass.ApplicationNumber), ref response); - Debug.LogMessage(LogEventLevel.Debug, "Console Response: {0}", response); + Debug.LogMessage(LogEventLevel.Debug, "Console Response: {0}", response); } } - /// - /// Enumeration of eUpdateStatus values - /// - public enum eUpdateStatus + /// + /// Enumeration of eUpdateStatus values + /// + public enum eUpdateStatus { /// /// UpdateStarted status diff --git a/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs b/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs index d55194fd..b41a9020 100644 --- a/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs +++ b/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs @@ -7,9 +7,10 @@ using PepperDash.Core; namespace PepperDash.Essentials.Core.Interfaces { - /// - /// Defines the contract for ILogStrings - /// + /// + /// Defines the contract for ILogStrings + /// + [Obsolete("ILogStrings is no longer supported and will be removed in a future release.")] public interface ILogStrings : IKeyed { /// diff --git a/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs b/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs index bb835919..32ef104a 100644 --- a/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs +++ b/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs @@ -7,15 +7,16 @@ using PepperDash.Core; namespace PepperDash.Essentials.Core.Interfaces { - /// - /// Defines the contract for ILogStringsWithLevel - /// + /// + /// Defines the contract for ILogStringsWithLevel + /// + [Obsolete("ILogStringsWithLevel is no longer supported and will be removed in a future release.")] public interface ILogStringsWithLevel : IKeyed { /// /// Defines a class that is capable of logging a string with an int level /// - void SendToLog(IKeyed device, Debug.ErrorLogLevel level,string logMessage); + void SendToLog(IKeyed device, Debug.ErrorLogLevel level, string logMessage); } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs b/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs index 9fce9c5b..708b7448 100644 --- a/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs +++ b/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs @@ -27,16 +27,19 @@ namespace PepperDash.Essentials.Core /// /// Control signal type /// + [Obsolete("UsbOutput is no longer supported and will be removed in a future release.")] UsbOutput = 8, /// /// Control signal type /// + [Obsolete("UsbInput is no longer supported and will be removed in a future release.")] UsbInput = 16, /// /// Secondary audio signal type /// + [Obsolete("SecondaryAudio is no longer supported and will be removed in a future release.")] SecondaryAudio = 32 } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs b/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs index 126bcd27..f383dd0a 100644 --- a/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs +++ b/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs @@ -17,6 +17,8 @@ namespace PepperDash.Essentials.Devices.Common.Cameras /// /// Represents a CameraVisca /// + [Obsolete("CameraVisca is no longer supported and will be removed in a future release. Use the CameraVisca plugin instead.")] + public class CameraVisca : CameraBase, IHasCameraPtzControl, ICommunicationMonitor, IHasCameraPresets, IHasPowerControlWithFeedback, IBridgeAdvanced, IHasCameraFocusControl, IHasAutoFocusMode { private readonly CameraViscaPropertiesConfig PropertiesConfig; diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs index 87f19e3f..a5df51a8 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Newtonsoft.Json; @@ -18,6 +19,7 @@ namespace PepperDash.Essentials.AppServer.Messengers /// Sets the interfaces implemented by the device sending the message /// /// + [Obsolete("SetInterfaces is no longer supported and will be removed in a future release. Interfaces for all devices are now retrieved via the /joinroom endpoint in the MobileControlWebsocketServer")] public void SetInterfaces(List interfaces) { Interfaces = interfaces; diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index 5a0ba2af..47de4d3e 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -801,6 +801,7 @@ namespace PepperDash.Essentials /// /// Fires up a logo server if not already running /// + [Obsolete("Logo server is no longer supported and will be removed in a future release.")] void LoadLogoServer() { if (ConfigReader.ConfigObject?.Rooms == null) From 8ba993ed668e741de3f63759e40c15efa8e28473 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 8 May 2026 13:19:04 -0600 Subject: [PATCH 32/46] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Config/Essentials/ConfigUpdater.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs index e940c573..2ba33df0 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs @@ -150,7 +150,7 @@ namespace PepperDash.Essentials.Core.Config // Directory exists, first clear any contents var archivedConfigFiles = ConfigReader.GetConfigFiles(archiveDirectoryPath + Global.DirectorySeparator + Global.ConfigFileName + ".bak"); - if (archivedConfigFiles != null || archivedConfigFiles.Length > 0) + if (archivedConfigFiles != null && archivedConfigFiles.Length > 0) { Debug.LogMessage(LogEventLevel.Information, "{0} Existing files found in archive folder. Deleting.", archivedConfigFiles.Length); From f1ef479301cbcd724da2165a91b02b6174f3d0ea Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 8 May 2026 13:19:46 -0600 Subject: [PATCH 33/46] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs index 68bbe362..125da1ec 100644 --- a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs +++ b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs @@ -171,14 +171,14 @@ namespace PepperDash.Core.WebApi.Presets J2SMaster.LoadWithJson(preset.Data); if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(preset, true)); + handler(this, new PresetReceivedEventArgs(preset, true)); } else // no existing preset { CurrentPreset = new Preset(); LoadDefaultPresetData(); if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(null, false)); + handler(this, new PresetReceivedEventArgs(null, false)); } } catch (HttpException e) From 2607180ab5351108fe43913a366e96479d34be52 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 8 May 2026 13:20:03 -0600 Subject: [PATCH 34/46] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs index 125da1ec..131430a2 100644 --- a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs +++ b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs @@ -107,11 +107,11 @@ namespace PepperDash.Core.WebApi.Presets var user = JsonConvert.DeserializeObject(resp.ContentString); CurrentUser = user; if (handler != null) - UserReceived(this, new UserReceivedEventArgs(user, true)); + handler(this, new UserReceivedEventArgs(user, true)); } else if (handler != null) - UserReceived(this, new UserReceivedEventArgs(null, false)); + handler(this, new UserReceivedEventArgs(null, false)); } /// From e5e1802da996433b4761fdc58c623cf13d787f93 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Sun, 10 May 2026 20:48:05 -0600 Subject: [PATCH 35/46] feat: enhance LoginRequestHandler to include detailed LoginResponse structure Co-authored-by: Copilot --- .../Logging/DebugWebsocketSink.cs | 35 ++++--------- .../RequestHandlers/LoginRequestHandler.cs | 52 ++++++++++++++++++- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 8986fcb4..99910ec9 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -15,12 +15,10 @@ using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; using Org.BouncyCastle.Crypto.Operators; -using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Math; using Org.BouncyCastle.Pkcs; using Org.BouncyCastle.Security; using Org.BouncyCastle.X509; -using System.Security.Cryptography; using Serilog.Formatting; using Serilog.Formatting.Json; @@ -244,9 +242,10 @@ namespace PepperDash.Core /// /// Loads a PKCS#12 file written by BouncyCastle and returns an with - /// private key attached via . - /// Using BouncyCastle's own reader avoids the .NET/Mono PFX parser, which can reject - /// BouncyCastle-generated archives on the Crestron runtime. + /// private key attached. + /// The PFX is parsed and re-encoded by BouncyCastle (ensuring format compatibility), then passed as + /// raw bytes to so neither RSACryptoServiceProvider nor the + /// EphemeralKeySet flag (unsupported on the Crestron/Mono runtime) is needed. /// private static X509Certificate2 LoadCertFromBouncyCastle(string certPath, string certPassword) { @@ -258,26 +257,12 @@ namespace PepperDash.Core var store = new Pkcs12StoreBuilder().Build(); store.Load(stream, passwordChars); - foreach (string alias in store.Aliases) + // Re-encode through BouncyCastle to guarantee PKCS#12 format compatibility, + // then hand raw bytes to X509Certificate2 — no RSACryptoServiceProvider needed. + using (var ms = new MemoryStream()) { - if (!store.IsKeyEntry(alias)) continue; - - var keyEntry = store.GetKey(alias); - var certChain = store.GetCertificateChain(alias); - if (certChain == null || certChain.Length == 0) continue; - - // Build X509Certificate2 from raw DER — no PFX parsing by .NET needed. - var cert = new X509Certificate2(certChain[0].Certificate.GetEncoded()); - - // Attach the private key via RSACryptoServiceProvider (available on all target runtimes). - var rsaParams = DotNetUtilities.ToRSAParameters( - (RsaPrivateCrtKeyParameters)keyEntry.Key); - var rsa = new RSACryptoServiceProvider(); - rsa.PersistKeyInCsp = false; - rsa.ImportParameters(rsaParams); - cert.PrivateKey = rsa; - - return cert; + store.Save(ms, passwordChars, new SecureRandom()); + return new X509Certificate2(ms.ToArray(), certPassword); } } } @@ -285,8 +270,6 @@ namespace PepperDash.Core { Array.Clear(passwordChars, 0, passwordChars.Length); } - - throw new InvalidOperationException("No key entry found in PKCS#12 store: " + certPath); } private void Start(int port, string certPath = "", string certPassword = "") diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index 5a4b7df7..c05e0cb6 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -1,5 +1,6 @@ using System; +using System.Collections.Generic; using Crestron.SimplSharp.CrestronAuthentication; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; @@ -91,7 +92,15 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers context.Response.StatusDescription = "OK"; context.Response.ContentType = "application/json"; context.Response.ContentEncoding = System.Text.Encoding.UTF8; - context.Response.Write(JsonConvert.SerializeObject(new { Token = token }, Formatting.Indented), false); + context.Response.Write(JsonConvert.SerializeObject( + new LoginResponse + { UserName = token.UserName, + Password = token.Password, + Access = token.Access, + State = token.State, + Groups = token.Groups, + ADConnect = token.ADConnect, + Valid = token.Valid }, Formatting.Indented), false); context.Response.End(); } catch (System.Exception ex) @@ -121,4 +130,45 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// public string Password { get; set; } } + + /// + /// Represents a LoginResponse + /// + internal class LoginResponse + { + /// + /// Gets or sets the username. + /// + public string UserName { get; set; } + + /// + /// Gets or sets the password. + /// + public string Password { get; set; } + + /// + /// Gets or sets the access level. + /// + public Authentication.UserAuthenticationLevelEnum Access { get; set; } + + /// + /// Gets or sets the token authenticated state. + /// + public Authentication.eTokenAuthenticatedState State { get; set; } + + /// + /// Gets or sets the list of groups. + /// + public List Groups { get; set; } + + /// + /// Gets or sets the active directory connection flag. + /// + public int ADConnect { get; set; } + + /// + /// Gets or sets the valid flag indicating whether the token is valid. + /// + public bool Valid { get; set; } + } } \ No newline at end of file From 1a5840c29a166425baab9f95fbc94ddfa130c75c Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 11 May 2026 09:48:12 -0600 Subject: [PATCH 36/46] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Web/RequestHandlers/LoginRequestHandler.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index c05e0cb6..f70d1ddb 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -93,14 +93,15 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers context.Response.ContentType = "application/json"; context.Response.ContentEncoding = System.Text.Encoding.UTF8; context.Response.Write(JsonConvert.SerializeObject( - new LoginResponse - { UserName = token.UserName, - Password = token.Password, - Access = token.Access, - State = token.State, - Groups = token.Groups, - ADConnect = token.ADConnect, - Valid = token.Valid }, Formatting.Indented), false); + new + { + UserName = token.UserName, + Access = token.Access, + State = token.State, + Groups = token.Groups, + ADConnect = token.ADConnect, + Valid = token.Valid + }, Formatting.Indented), false); context.Response.End(); } catch (System.Exception ex) From d879430616fad5255941d50fca218e9923f56020 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 11 May 2026 09:50:39 -0600 Subject: [PATCH 37/46] feat: update LoginResponse structure to include Password in the token response --- .../RequestHandlers/LoginRequestHandler.cs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index f70d1ddb..3d9fdac3 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -93,15 +93,19 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers context.Response.ContentType = "application/json"; context.Response.ContentEncoding = System.Text.Encoding.UTF8; context.Response.Write(JsonConvert.SerializeObject( - new - { - UserName = token.UserName, - Access = token.Access, - State = token.State, - Groups = token.Groups, - ADConnect = token.ADConnect, - Valid = token.Valid - }, Formatting.Indented), false); + new + { + Token = new LoginResponse + { + UserName = token.UserName, + Password = token.Password, + Access = token.Access, + State = token.State, + Groups = token.Groups, + ADConnect = token.ADConnect, + Valid = token.Valid + } + }, Formatting.Indented), false); context.Response.End(); } catch (System.Exception ex) From a9dd57fdaf70b55f63bf7b4a3c2e2088aee244e7 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 11 May 2026 09:51:48 -0600 Subject: [PATCH 38/46] refactor: remove Password from LoginResponse and related token assignment --- .../Web/RequestHandlers/LoginRequestHandler.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index 3d9fdac3..949f8bd5 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -98,7 +98,6 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers Token = new LoginResponse { UserName = token.UserName, - Password = token.Password, Access = token.Access, State = token.State, Groups = token.Groups, @@ -146,11 +145,6 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// public string UserName { get; set; } - /// - /// Gets or sets the password. - /// - public string Password { get; set; } - /// /// Gets or sets the access level. /// From b318e7f365d5e405d84a62ab5164add4fde4ee64 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 11 May 2026 09:55:27 -0600 Subject: [PATCH 39/46] fix: validate certificate for private key presence in DebugWebsocketSink Co-authored-by: Copilot --- src/PepperDash.Core/Logging/DebugWebsocketSink.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 99910ec9..eeba5772 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -262,7 +262,13 @@ namespace PepperDash.Core using (var ms = new MemoryStream()) { store.Save(ms, passwordChars, new SecureRandom()); - return new X509Certificate2(ms.ToArray(), certPassword); + var cert = new X509Certificate2(ms.ToArray(), certPassword); + + if (!cert.HasPrivateKey) + throw new InvalidOperationException( + string.Format("Certificate loaded from '{0}' does not contain a private key and cannot be used as a server certificate.", certPath)); + + return cert; } } } From c9f5af184b301c03f34cd8990ed9019977e57114 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 1 May 2026 14:29:54 -0700 Subject: [PATCH 40/46] feat: add support for UdpClient in communication methods and implement GenericUdpClient class --- docs/docs/usage/GenericComm.md | 7 +- src/PepperDash.Core/Comm/GenericUdpClient.cs | 388 ++++++++++++++++++ src/PepperDash.Core/Comm/eControlMethods.cs | 4 + .../Comm and IR/CommFactory.cs | 11 + 4 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 src/PepperDash.Core/Comm/GenericUdpClient.cs diff --git a/docs/docs/usage/GenericComm.md b/docs/docs/usage/GenericComm.md index 243536e5..1c1642a8 100644 --- a/docs/docs/usage/GenericComm.md +++ b/docs/docs/usage/GenericComm.md @@ -183,11 +183,12 @@ namespace PepperDash.Core Cresnet = 8, Cec = 9, Udp = 10, + UdpClient = 11, } } ``` -These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, and ```Udp```. + These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, ```UdpClient```, and ```Udp```. ##### ComParams @@ -287,7 +288,7 @@ This property maps to the number of the port on the device you have mapped the r ##### TcpSshParams -A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket. +A ```Ssh```, ```TcpIp```, ```UdpClient```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket. ```Json { @@ -304,7 +305,7 @@ A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties``` **```address```** -This is the IP address, hostname, or FQDN of the resource you wish to open a socket to. In the case of a UDP device, you can set either a single whitelist address with this data, or an appropriate broadcast address. +This is the IP address, hostname, or FQDN of the resource you wish to open a socket to. Use ```UdpClient``` for outbound UDP to a remote endpoint. Use ```Udp``` when you need Essentials to bind a local UDP listener. **```port```** diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs new file mode 100644 index 00000000..95954eea --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -0,0 +1,388 @@ +using System; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using ThreadingTimeout = System.Threading.Timeout; +using NetSocketException = System.Net.Sockets.SocketException; + +namespace PepperDash.Core +{ + /// + /// A class to handle basic UDP communications to a remote endpoint + /// + public class GenericUdpClient : Device, ISocketStatusWithStreamDebugging, IAutoReconnect + { + private const string SplusKey = "Uninitialized UdpClient"; + + private readonly object stateLock = new object(); + private readonly Timer reconnectTimer; + + private UdpClient client; + private CancellationTokenSource receiveCancellationTokenSource; + private bool connectEnabled; + private SocketStatus clientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + + /// + /// Object to enable stream debugging + /// + public CommunicationStreamDebugging StreamDebugging { get; private set; } + + /// + /// Fires when data is received from the remote endpoint and returns it as a byte array + /// + public event EventHandler BytesReceived; + + /// + /// Fires when data is received from the remote endpoint and returns it as text + /// + public event EventHandler TextReceived; + + /// + /// Fires when the socket status changes + /// + public event EventHandler ConnectionChange; + + /// + /// Address of remote endpoint + /// + public string Hostname { get; set; } + + /// + /// Port on remote endpoint + /// + public int Port { get; set; } + + /// + /// Another S+ helper because large port numbers can be treated as signed ints + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// True when the local socket is created and associated with the configured remote endpoint + /// + public bool IsConnected + { + get { return ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// The current socket status of the client + /// + public SocketStatus ClientStatus + { + get + { + lock (stateLock) + { + return clientStatus; + } + } + private set + { + var shouldFireEvent = false; + + lock (stateLock) + { + if (clientStatus != value) + { + clientStatus = value; + shouldFireEvent = true; + } + } + + if (shouldFireEvent) + ConnectionChange?.Invoke(this, new GenericSocketStatusChageEventArgs(this)); + } + } + + /// + /// Ushort representation of client status + /// + public ushort UStatus + { + get { return (ushort)ClientStatus; } + } + + /// + /// Gets or sets the AutoReconnect + /// + public bool AutoReconnect { get; set; } + + /// + /// S+ helper for AutoReconnect + /// + public ushort UAutoReconnect + { + get { return (ushort)(AutoReconnect ? 1 : 0); } + set { AutoReconnect = value == 1; } + } + + /// + /// Milliseconds to wait before attempting to reconnect. Defaults to 5000 + /// + public int AutoReconnectIntervalMs { get; set; } + + /// + /// Constructor + /// + public GenericUdpClient(string key, string address, int port, int bufferSize) + : base(key) + { + StreamDebugging = new CommunicationStreamDebugging(key); + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + AutoReconnectIntervalMs = 5000; + Hostname = address; + Port = port; + BufferSize = bufferSize; + + reconnectTimer = new Timer(o => + { + if (connectEnabled) + Connect(); + }, null, ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + } + + /// + /// Constructor for S+ + /// + public GenericUdpClient() + : base(SplusKey) + { + StreamDebugging = new CommunicationStreamDebugging(SplusKey); + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + AutoReconnectIntervalMs = 5000; + BufferSize = 2000; + + reconnectTimer = new Timer(o => + { + if (connectEnabled) + Connect(); + }, null, ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + } + + /// + /// Initialize method + /// + public void Initialize(string key) + { + Key = key; + } + + private void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping) + { + Debug.Console(1, this, "Program stopping. Closing connection"); + Deactivate(); + } + } + + /// + /// Deactivate method + /// + public override bool Deactivate() + { + Disconnect(); + return true; + } + + /// + /// Connect method + /// + public void Connect() + { + if (string.IsNullOrEmpty(Hostname)) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': No address set", Key); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + return; + } + + if (Port < 1 || Port > 65535) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': Invalid port", Key); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + return; + } + + lock (stateLock) + { + connectEnabled = true; + + if (client != null) + return; + + try + { + receiveCancellationTokenSource = new CancellationTokenSource(); + client = new UdpClient(); + client.Client.ReceiveBufferSize = BufferSize; + client.Client.SendBufferSize = BufferSize; + client.Connect(Hostname, Port); + ClientStatus = SocketStatus.SOCKET_STATUS_CONNECTED; + reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + StartReceive(receiveCancellationTokenSource.Token); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Error connecting UDP client {0}", this, Key); + CleanupClient(); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + StartReconnectTimer(); + } + } + } + + /// + /// Disconnect method + /// + public void Disconnect() + { + lock (stateLock) + { + connectEnabled = false; + reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + CleanupClient(); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + } + } + + /// + /// SendText method + /// + public void SendText(string text) + { + var bytes = Encoding.GetEncoding(28591).GetBytes(text); + SendBytes(bytes); + } + + /// + /// SendBytes method + /// + public void SendBytes(byte[] bytes) + { + if (bytes == null) + return; + + try + { + this.PrintSentBytes(bytes); + + if (!IsConnected || client == null) + Connect(); + + var udpClient = client; + if (!IsConnected || udpClient == null) + return; + + udpClient.Send(bytes, bytes.Length); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Error sending UDP bytes for {0}", this, Key); + HandleDisconnected(); + } + } + + private void StartReceive(CancellationToken token) + { + Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + try + { + var udpClient = client; + if (udpClient == null) + return; + + var result = await udpClient.ReceiveAsync().ConfigureAwait(false); + var bytes = result.Buffer; + if (bytes == null || bytes.Length == 0) + continue; + + var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); + + this.PrintReceivedBytes(bytes); + this.PrintReceivedText(text); + + BytesReceived?.Invoke(this, new GenericCommMethodReceiveBytesArgs(bytes)); + TextReceived?.Invoke(this, new GenericCommMethodReceiveTextArgs(text)); + } + catch (ObjectDisposedException) + { + return; + } + catch (InvalidOperationException) + { + return; + } + catch (NetSocketException ex) + { + Debug.LogMessage(ex, "UDP receive error for {0}", this, Key); + HandleDisconnected(); + return; + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Unexpected UDP receive error for {0}", this, Key); + HandleDisconnected(); + return; + } + } + }, token); + } + + private void HandleDisconnected() + { + lock (stateLock) + { + CleanupClient(); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + StartReconnectTimer(); + } + } + + private void StartReconnectTimer() + { + if (AutoReconnect && connectEnabled) + reconnectTimer.Change(AutoReconnectIntervalMs, ThreadingTimeout.Infinite); + } + + private void CleanupClient() + { + if (receiveCancellationTokenSource != null) + { + receiveCancellationTokenSource.Cancel(); + receiveCancellationTokenSource.Dispose(); + receiveCancellationTokenSource = null; + } + + if (client != null) + { + client.Close(); + client = null; + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/eControlMethods.cs b/src/PepperDash.Core/Comm/eControlMethods.cs index b807fdc5..006bb987 100644 --- a/src/PepperDash.Core/Comm/eControlMethods.cs +++ b/src/PepperDash.Core/Comm/eControlMethods.cs @@ -56,6 +56,10 @@ namespace PepperDash.Core /// Udp, /// + /// UDP client + /// + UdpClient, + /// /// HTTP client /// Http, diff --git a/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs b/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs index 9318e04b..b28331a0 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs @@ -96,6 +96,17 @@ namespace PepperDash.Essentials.Core comm = udp; break; } + case eControlMethod.UdpClient: + { + var udpClient = new GenericUdpClient(deviceConfig.Key + "-udpClient", c.Address, c.Port, c.BufferSize) + { + AutoReconnect = c.AutoReconnect + }; + if (udpClient.AutoReconnect) + udpClient.AutoReconnectIntervalMs = c.AutoReconnectIntervalMs; + comm = udpClient; + break; + } case eControlMethod.Telnet: break; case eControlMethod.SecureTcpIp: From b83af26b774e95b1e4042fc7ced1118b85c34470 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:01:08 -0700 Subject: [PATCH 41/46] 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/GenericUdpClient.cs | 65 ++++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index 95954eea..ec7f0e7f 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -225,30 +225,71 @@ namespace PepperDash.Core return; } + var hostname = Hostname; + var port = Port; + var bufferSize = BufferSize; + UdpClient newClient = null; + CancellationTokenSource newReceiveCancellationTokenSource = null; + CancellationToken startReceiveToken = default(CancellationToken); + var shouldStartReceive = false; + lock (stateLock) { connectEnabled = true; if (client != null) return; + } - try + try + { + newReceiveCancellationTokenSource = new CancellationTokenSource(); + newClient = new UdpClient(); + newClient.Client.ReceiveBufferSize = bufferSize; + newClient.Client.SendBufferSize = bufferSize; + newClient.Connect(hostname, port); + + lock (stateLock) { - receiveCancellationTokenSource = new CancellationTokenSource(); - client = new UdpClient(); - client.Client.ReceiveBufferSize = BufferSize; - client.Client.SendBufferSize = BufferSize; - client.Connect(Hostname, Port); + if (!connectEnabled || client != null) + { + newClient.Close(); + newReceiveCancellationTokenSource.Cancel(); + newReceiveCancellationTokenSource.Dispose(); + return; + } + + receiveCancellationTokenSource = newReceiveCancellationTokenSource; + client = newClient; ClientStatus = SocketStatus.SOCKET_STATUS_CONNECTED; reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); - StartReceive(receiveCancellationTokenSource.Token); + startReceiveToken = receiveCancellationTokenSource.Token; + shouldStartReceive = true; } - catch (Exception ex) + + if (shouldStartReceive) + StartReceive(startReceiveToken); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Error connecting UDP client {0}", this, Key); + + if (newClient != null) + newClient.Close(); + + if (newReceiveCancellationTokenSource != null) { - Debug.LogMessage(ex, "Error connecting UDP client {0}", this, Key); - CleanupClient(); - ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; - StartReconnectTimer(); + newReceiveCancellationTokenSource.Cancel(); + newReceiveCancellationTokenSource.Dispose(); + } + + lock (stateLock) + { + if (connectEnabled && client == null) + { + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + StartReconnectTimer(); + } } } } From e57bc43a109166575a93d0c8f64fe9af2e5331f7 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:04:28 -0700 Subject: [PATCH 42/46] 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/GenericUdpClient.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index ec7f0e7f..c9a54505 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -313,6 +313,8 @@ namespace PepperDash.Core /// public void SendText(string text) { + this.PrintSentText(text); + var bytes = Encoding.GetEncoding(28591).GetBytes(text); SendBytes(bytes); } From e6583f7824350699eb7caebdc61026cd8bb3694d Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:06:25 -0700 Subject: [PATCH 43/46] 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/GenericUdpClient.cs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index c9a54505..3d93bada 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -383,14 +383,26 @@ namespace PepperDash.Core catch (NetSocketException ex) { Debug.LogMessage(ex, "UDP receive error for {0}", this, Key); - HandleDisconnected(); - return; + + if (AutoReconnect) + { + HandleDisconnected(); + return; + } + + continue; } catch (Exception ex) { Debug.LogMessage(ex, "Unexpected UDP receive error for {0}", this, Key); - HandleDisconnected(); - return; + + if (AutoReconnect) + { + HandleDisconnected(); + return; + } + + continue; } } }, token); From 4f2d2ca746b23329065be95cb7bfcd890e5bac1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 20:09:51 +0000 Subject: [PATCH 44/46] fix: log udp client send failures when disconnected Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/761a7a78-c51f-474b-9000-baa9a232c0d0 Co-authored-by: jonnyarndt <21110580+jonnyarndt@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index 3d93bada..2c2d0f1c 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -336,7 +336,10 @@ namespace PepperDash.Core var udpClient = client; if (!IsConnected || udpClient == null) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': Cannot send bytes because the client is not connected", Key); return; + } udpClient.Send(bytes, bytes.Length); } @@ -440,4 +443,4 @@ namespace PepperDash.Core } } } -} \ No newline at end of file +} From d47cfd5e62b864dccd47d8575c732e57fc980fe5 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:13:24 -0700 Subject: [PATCH 45/46] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/docs/usage/GenericComm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/usage/GenericComm.md b/docs/docs/usage/GenericComm.md index 1c1642a8..16f467e2 100644 --- a/docs/docs/usage/GenericComm.md +++ b/docs/docs/usage/GenericComm.md @@ -188,7 +188,7 @@ namespace PepperDash.Core } ``` - These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, ```UdpClient```, and ```Udp```. +These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, ```UdpClient```, and ```Udp```. ##### ComParams From 18f7000d76df69210298ba13544a233c6e52f98e Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:45:50 -0700 Subject: [PATCH 46/46] fix: add udpClient behavior to throttle receive errors and reset upon valid traffic arrival --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index 2c2d0f1c..41792537 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -23,6 +23,7 @@ namespace PepperDash.Core private UdpClient client; private CancellationTokenSource receiveCancellationTokenSource; private bool connectEnabled; + private bool connectionRefusedLogged; private SocketStatus clientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; /// @@ -367,6 +368,8 @@ namespace PepperDash.Core if (bytes == null || bytes.Length == 0) continue; + connectionRefusedLogged = false; + var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); this.PrintReceivedBytes(bytes); @@ -385,6 +388,20 @@ namespace PepperDash.Core } catch (NetSocketException ex) { + if (ex.SocketErrorCode == SocketError.ConnectionRefused) + { + if (!connectionRefusedLogged) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, + "GenericUdpClient '{0}': Remote endpoint refused UDP traffic or is no longer listening", + Key); + connectionRefusedLogged = true; + } + + HandleDisconnected(); + return; + } + Debug.LogMessage(ex, "UDP receive error for {0}", this, Key); if (AutoReconnect)