Conversation
* Performance improvment by mapping out midpoint to sinks on startup * Use existing routing methods * Debounce event handling * Check all signal types for route updates
* 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
There was a problem hiding this comment.
Pull request overview
This PR adds route/tieline visualization and introduces several routing performance optimizations in Essentials Core, including TieLine indexing, “impossible route” caching, and startup pre-mapping of route descriptors. It also expands diagnostics via console output updates and a new Web API endpoint to fetch routing devices and tielines in one call.
Changes:
- Added console commands to list tielines and visualize mapped/current routes, plus startup route mapping after tielines load.
- Implemented routing optimizations: tieline indexes, impossible-route caching, and precomputed route-descriptor tables with preloaded lookup during execution.
- Added a CWS endpoint to return routing devices + tielines together, and improved
getroutingportsconsole output.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/PepperDash.Essentials/ControlSystem.cs | Adds console commands and triggers route mapping after tielines load |
| src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs | New endpoint returning routing devices/ports and tielines |
| src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs | Registers the new routingDevicesAndTieLines route |
| src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs | Adds midpoint→sink optimization and debounced sink updates; refactors root-source discovery |
| src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs | Simplifies/modernizes descriptor implementation and string output |
| src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs | Adds public descriptor enumeration and changes de-dupe logic |
| src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs | Improves route string formatting; minor cleanup |
| src/PepperDash.Essentials.Core/Routing/Extensions.cs | Adds tieline indexing, impossible-route caching, startup mapping, and preloaded route lookup |
| src/PepperDash.Essentials.Core/Devices/DeviceManager.cs | Improves console output to include port signal type |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| { | ||
| try | ||
| { | ||
| if (args.Contains("?")) |
There was a problem hiding this comment.
args can be null/empty for console commands; calling args.Contains("?") will throw a NullReferenceException. Guard with if (!string.IsNullOrEmpty(args) && args.Contains("?")) (or args?.Contains("?") == true) in ListTieLines, VisualizeRoutes, and VisualizeCurrentRoutes before checking for help.
| if (args.Contains("?")) | |
| if (!string.IsNullOrEmpty(args) && args.Contains("?")) |
| 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<string>(); | ||
|
|
||
| midpointToSinksMap[midpointKey].Add(sink.Key); | ||
| } |
There was a problem hiding this comment.
midpointToSinksMap is built once using each sink’s current input at startup. When a sink later changes inputs, the upstream midpoint set can change, but the map is never updated—so subsequent HandleMidpointUpdate calls may fail to refresh sinks that are now downstream of that midpoint. Consider updating/rebuilding the map in HandleSinkUpdate (on input changes) or computing downstream sinks from topology rather than current state.
| // 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
updateTimers is accessed from both event handlers and the CTimer callback without synchronization. This can race (e.g., timer callback removing an entry while another thread replaces it), causing KeyNotFoundException or corrupted state. Add a lock around all dictionary access (or switch to a thread-safe collection) and consider removing the old entry before disposing/replacing the timer.
| // Found a valid route - return the source TieLine | ||
| var sourceTieLine = TieLineCollection.Default.FirstOrDefault(tl => | ||
| tl.SourcePort.ParentDevice.Key == source.Key && | ||
| tl.Type.HasFlag(signalType)); |
There was a problem hiding this comment.
GetRootTieLine returns the first TieLine whose source device matches and supports the signal type, but it doesn’t verify that it’s the specific output port / tie line that actually starts the discovered route. If a source device has multiple output ports / tielines of the same type, this can return the wrong source and therefore set incorrect CurrentSourceInfo. Use information from the discovered route (e.g., first hop’s OutputPort/InputPort) to identify the correct source tieline/port, or return the actual tieline traversed rather than a FirstOrDefault by device key.
| // Found a valid route - return the source TieLine | |
| var sourceTieLine = TieLineCollection.Default.FirstOrDefault(tl => | |
| tl.SourcePort.ParentDevice.Key == source.Key && | |
| tl.Type.HasFlag(signalType)); | |
| var firstHop = route.Routes.FirstOrDefault(); | |
| if (firstHop == null) | |
| continue; | |
| // Found a valid route - return the specific source TieLine that starts the discovered route | |
| var sourceTieLine = TieLineCollection.Default.FirstOrDefault(tl => | |
| tl.SourcePort.ParentDevice.Key == source.Key && | |
| tl.Type.HasFlag(signalType) && | |
| tl.SourcePort == firstHop.OutputPort && | |
| tl.DestinationPort == firstHop.InputPort); |
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
MapDestinationsToSources iterates every source.OutputPorts and passes each outputPort into GetRouteToSource as the sourcePort, implying distinct routes per source output. However RouteDescriptor/RouteDescriptorCollection don’t record or key on the chosen source output port, so different output-port routes will be de-duplicated/overwritten and later lookups can’t reliably select the intended path. Either stop iterating source.OutputPorts here, or extend RouteDescriptor (and Add/Get logic) to include and match sourcePort.
| 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); | |
| } | |
| } | |
| var outputPort = source.OutputPorts.FirstOrDefault(p => p.Type == inputPort.Type) | |
| ?? source.OutputPorts.FirstOrDefault(); | |
| if (outputPort == null) | |
| { | |
| continue; | |
| } | |
| 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); | |
| } |
| 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)); | ||
| } |
There was a problem hiding this comment.
Pre-mapped route lookup ignores request.SourcePort. When a route request targets a specific source output port, this lookup can return the wrong descriptor (or miss a valid one) because it only matches source/destination/(destinationPort). Include request.SourcePort (or its key) in the predicate, and ensure the mapped descriptors actually store that source-port identity.
| // 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; |
There was a problem hiding this comment.
_impossibleRoutes is a static HashSet<string> that is read/written from GetRouteToSource. Since GetRouteToSource is called from multiple threads in this PR (e.g., routing queue + RoutingFeedbackManager’s CTimer callback), HashSet access can race and throw or corrupt state. Protect it with a lock or replace with a thread-safe collection (e.g., ConcurrentDictionary<string,bool>).
| // 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))); | ||
|
|
There was a problem hiding this comment.
Route de-duplication keys on source/destination/signalType/inputPort only. With the new startup mapping iterating source.OutputPorts (and route requests supporting sourcePortKey), routes that differ only by source output port will be treated as duplicates and one will be dropped, making source-port-specific routing unreliable. Consider including the selected source output port in the RouteDescriptor model and in this uniqueness check (or remove source-port iteration if it’s not meant to be supported).
…s. Resolve issues with client closing websocket connection to DebugWebsocketSink
This pull request introduces significant performance optimizations and new features to the routing logic in the Essentials Core, particularly in the
Extensionsclass. The main improvements include the addition of indexed lookups forTieLines, caching of impossible routes to avoid redundant calculations, and the introduction of a pre-mapping mechanism for route descriptors. These changes are aimed at making route discovery and execution much faster and more efficient, especially in large systems.Routing Performance Optimizations:
_tieLinesByDestination,_tieLinesBySource) forTieLinesto speed up route lookups and provided methods to initialize and use these indexes instead of repeated LINQ queries._impossibleRoutes) for impossible routes to prevent repeated attempts at finding non-existent paths, including logic to add to and clear this cache. [1] [2]Route Descriptor Pre-mapping and Utilization:
RouteDescriptorsdictionary, mapping each signal type to a collection ofRouteDescriptorobjects, and implemented aMapDestinationsToSourcesmethod to precompute and store all possible routes at startup. [1] [2]Other Improvements and Bug Fixes:
GetRouteToSourceto ensure only valid, non-empty route descriptors are returned, preventing downstream errors.DeviceManager.GetRoutingPortsto display both the key and signal type for each port, aiding in debugging and diagnostics.These changes collectively make routing operations more scalable and reliable, particularly in environments with many devices and complex routing requirements.