From 079f2beb8bba9f2cf80a873b9615416af3e11eee Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 14 Jul 2026 19:34:14 -0600 Subject: [PATCH] feat: enhance routing graph handling for tile-sink children and add RoutingGraphHelpers utility class --- .../GetRoutingDevicesAndTieLinesHandler.cs | 188 ++++++++++++++++-- .../Web/RoutingFeedbackWebsocket.cs | 79 +++++--- .../Web/RoutingGraphHelpers.cs | 89 +++++++++ .../MobileControlWebsocketServer.cs | 23 ++- 4 files changed, 335 insertions(+), 44 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/Web/RoutingGraphHelpers.cs diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs index d532e7a2..0c5a3fae 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs @@ -5,6 +5,7 @@ using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core; using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Web; namespace PepperDash.Essentials.Core.Web.RequestHandlers { @@ -27,9 +28,17 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers { var devices = new List(); + // Tile-sink children of an IRoutingSinkWithLayouts device (e.g. a multiview decoder's + // per-window sinks) are rendered as synthesized input ports on the parent's node instead + // of as their own separate nodes - see BuildRoutingDeviceInfo below. + var tileChildren = RoutingGraphHelpers.BuildTileChildMap(); + // Get all devices from DeviceManager foreach (var device in DeviceManager.AllDevices) { + if (tileChildren.ContainsKey(device.Key)) + continue; + var deviceInfo = new RoutingDeviceInfo { Key = device.Key, @@ -68,6 +77,27 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers deviceInfo.HasInputsAndOutputs = true; } + // Devices implementing IRoutingSinkWithLayouts (e.g. a multiview decoder) don't + // implement IRoutingInputs themselves - their tiles do. Synthesize one input port per + // tile (qualified so multiple tiles don't collide) so the graph can render a single + // node with one edge-target input per tile. + if (device is IRoutingSinkWithLayouts layoutDevice) + { + deviceInfo.HasInputs = true; + + var tilePorts = layoutDevice.WindowTileSinks + .OrderBy(kvp => kvp.Key) + .SelectMany(kvp => (kvp.Value as IRoutingInputs)?.InputPorts.Select(p => new PortInfo + { + Key = RoutingGraphHelpers.QualifyTilePortKey(kvp.Key, p.Key), + SignalType = p.Type.ToString(), + ConnectionType = p.ConnectionType.ToString(), + IsInternal = p.IsInternal + }) ?? []); + + deviceInfo.InputPorts = (deviceInfo.InputPorts ?? []).Concat(tilePorts).ToList(); + } + // Only include devices that have routing capabilities if (deviceInfo.HasInputs || deviceInfo.HasOutputs) { @@ -75,15 +105,28 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers } } - // Get all tielines - var tielines = TieLineCollection.Default.Select(tl => new TieLineInfo + // Get all tielines, remapping any that target a tile-sink child so they point at its + // IRoutingSinkWithLayouts parent's node/qualified port instead. + var tielines = TieLineCollection.Default.Select(tl => { - 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 + var destinationDeviceKey = tl.DestinationPort.ParentDevice.Key; + var destinationPortKey = tl.DestinationPort.Key; + + if (tileChildren.TryGetValue(destinationDeviceKey, out var tileInfo)) + { + destinationDeviceKey = tileInfo.Parent.Key; + destinationPortKey = RoutingGraphHelpers.QualifyTilePortKey(tileInfo.TileNumber, destinationPortKey); + } + + return new TieLineInfo + { + SourceDeviceKey = tl.SourcePort.ParentDevice.Key, + SourcePortKey = tl.SourcePort.Key, + DestinationDeviceKey = destinationDeviceKey, + DestinationPortKey = destinationPortKey, + SignalType = tl.Type.ToString(), + IsInternal = tl.IsInternal + }; }).ToList(); // Get current active routes from DefaultCollection, grouped by signal type @@ -92,17 +135,43 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers .Select(g => new CurrentRouteGroupInfo { SignalType = g.Key, - Routes = [.. g.Select(d => new ActiveRouteInfo + Routes = [.. g.Select(d => { - SourceDeviceKey = d.Source.Key, - DestinationDeviceKey = d.Destination.Key, - DestinationInputPortKey = d.InputPort?.Key, - Steps = [.. d.Routes.Select(r => new RouteSwitchStepInfo + var destinationDeviceKey = d.Destination.Key; + var destinationInputPortKey = d.InputPort?.Key; + + if (tileChildren.TryGetValue(destinationDeviceKey, out var tileInfo)) { - SwitchingDeviceKey = r.SwitchingDevice?.Key, - InputPortKey = r.InputPort?.Key, - OutputPortKey = r.OutputPort?.Key - })] + destinationDeviceKey = tileInfo.Parent.Key; + if (destinationInputPortKey != null) + destinationInputPortKey = RoutingGraphHelpers.QualifyTilePortKey(tileInfo.TileNumber, destinationInputPortKey); + } + + return new ActiveRouteInfo + { + SourceDeviceKey = d.Source.Key, + DestinationDeviceKey = destinationDeviceKey, + DestinationInputPortKey = destinationInputPortKey, + Steps = [.. d.Routes.Select(r => + { + var switchingDeviceKey = r.SwitchingDevice?.Key; + var inputPortKey = r.InputPort?.Key; + + if (switchingDeviceKey != null && tileChildren.TryGetValue(switchingDeviceKey, out var stepTileInfo)) + { + switchingDeviceKey = stepTileInfo.Parent.Key; + if (inputPortKey != null) + inputPortKey = RoutingGraphHelpers.QualifyTilePortKey(stepTileInfo.TileNumber, inputPortKey); + } + + return new RouteSwitchStepInfo + { + SwitchingDeviceKey = switchingDeviceKey, + InputPortKey = inputPortKey, + OutputPortKey = r.OutputPort?.Key + }; + })] + }; })] }).ToList(); @@ -110,7 +179,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers { Devices = devices, TieLines = tielines, - CurrentRoutes = currentRoutes + CurrentRoutes = currentRoutes, + SinkCurrentSources = BuildSinkCurrentSources(tileChildren) }; var jsonResponse = JsonConvert.SerializeObject(response, Formatting.Indented); @@ -122,6 +192,50 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers context.Response.Write(jsonResponse, false); context.Response.End(); } + + /// + /// Builds current-source info for every sink device, read directly from each sink's own + /// bookkeeping (see ). + /// Unlike -based , this + /// also reflects routes made via a device-specific bulk API (e.g. + /// IHasDynamicMultiviewLayout.ApplyDynamicLayout) that never creates a + /// or at all, so it's what the dev tools app + /// should use to seed initial sink-routing state on page load. + /// + private static List BuildSinkCurrentSources( + Dictionary tileChildren) + { + var result = new List(); + + foreach (var device in DeviceManager.AllDevices.OfType()) + { + if (device.CurrentInputPort == null) + continue; + + var sourceKey = RoutingGraphHelpers.GetCurrentSourceKey(device); + if (string.IsNullOrEmpty(sourceKey)) + continue; + + var deviceKey = device.Key; + var inputPortKey = device.CurrentInputPort.Key; + + if (tileChildren.TryGetValue(deviceKey, out var tileInfo)) + { + deviceKey = tileInfo.Parent.Key; + inputPortKey = RoutingGraphHelpers.QualifyTilePortKey(tileInfo.TileNumber, inputPortKey); + } + + result.Add(new SinkCurrentSourceInfo + { + DeviceKey = deviceKey, + InputPortKey = inputPortKey, + SourceDeviceKey = sourceKey, + SignalType = device.CurrentInputPort.Type.ToString() + }); + } + + return result; + } } /// @@ -148,6 +262,44 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// [JsonProperty("currentRoutes")] public List CurrentRoutes { get; set; } + + /// + /// Gets or sets the current source feeding each sink device, read directly from each sink's own + /// current-source bookkeeping. Covers routes made via device-specific bulk APIs (e.g. + /// dynamic multiview layouts) that does not. + /// + [JsonProperty("sinkCurrentSources")] + public List SinkCurrentSources { get; set; } + } + + /// + /// Represents the source currently feeding a single sink device's input port + /// + public class SinkCurrentSourceInfo + { + /// + /// Gets or sets the key of the sink device (or its IRoutingSinkWithLayouts parent, if this is a tile) + /// + [JsonProperty("deviceKey")] + public string DeviceKey { get; set; } + + /// + /// Gets or sets the key of the input port currently receiving the source + /// + [JsonProperty("inputPortKey")] + public string InputPortKey { get; set; } + + /// + /// Gets or sets the key of the device currently feeding this input + /// + [JsonProperty("sourceDeviceKey")] + public string SourceDeviceKey { get; set; } + + /// + /// Gets or sets the signal type of the input port (e.g., AudioVideo, Audio, Video, etc.) + /// + [JsonProperty("signalType")] + public string SignalType { get; set; } } /// diff --git a/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs b/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs index 5801116e..4e53e261 100644 --- a/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs +++ b/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs @@ -33,6 +33,11 @@ public class RoutingFeedbackWebsocket : IKeyed private readonly Dictionary _debounceTimers = new Dictionary(); + // Tile-sink children of an IRoutingSinkWithLayouts device (e.g. a multiview decoder's per-window + // sinks) are reported to clients as synthesized inputs on the parent's node, rather than as their + // own separate nodes - see RoutingGraphHelpers. Rebuilt whenever the server (re)starts. + private Dictionary _tileChildren = new Dictionary(); + private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver(), @@ -157,7 +162,7 @@ public class RoutingFeedbackWebsocket : IKeyed internal string GetSnapshotMessage() { var midpointRoutes = new Dictionary>(); - var sinkRoutes = new Dictionary(); + var sinkRoutes = new Dictionary>(); // Collect midpoint current routes var midpointDevices = DeviceManager.AllDevices.OfType(); @@ -177,27 +182,42 @@ public class RoutingFeedbackWebsocket : IKeyed .ToList(); } - // Collect sink current sources + // Collect sink current sources directly from each sink's own current-source bookkeeping + // (ICurrentSources, part of IRoutingSinkWithFeedback), which is authoritative regardless of + // whether the route was made via a tie line (ReleaseAndMakeRoute) or a device-specific bulk + // API (e.g. IHasDynamicMultiviewLayout.ApplyDynamicLayout) that never touches + // TieLineCollection/RouteDescriptorCollection at all. var sinkDevices = DeviceManager.AllDevices.OfType(); foreach (var device in sinkDevices) { if (device.CurrentInputPort == null) continue; - // Trace back to find source - var tieLine = TieLineCollection.Default.FirstOrDefault(tl => - tl.DestinationPort.Key == device.CurrentInputPort.Key && - tl.DestinationPort.ParentDevice.Key == device.CurrentInputPort.ParentDevice.Key); + var sourceKey = RoutingGraphHelpers.GetCurrentSourceKey(device); + if (string.IsNullOrEmpty(sourceKey)) + continue; - if (tieLine != null) + var deviceKey = device.Key; + var inputPortKey = device.CurrentInputPort.Key; + + if (_tileChildren.TryGetValue(deviceKey, out var tileInfo)) { - sinkRoutes[device.Key] = new SinkRouteDto - { - InputPortKey = device.CurrentInputPort.Key, - SourceDeviceKey = tieLine.SourcePort.ParentDevice.Key, - SignalType = device.CurrentInputPort.Type.ToString() - }; + deviceKey = tileInfo.Parent.Key; + inputPortKey = RoutingGraphHelpers.QualifyTilePortKey(tileInfo.TileNumber, inputPortKey); } + + if (!sinkRoutes.TryGetValue(deviceKey, out var routes)) + { + routes = new List(); + sinkRoutes[deviceKey] = routes; + } + + routes.Add(new SinkRouteDto + { + InputPortKey = inputPortKey, + SourceDeviceKey = sourceKey, + SignalType = device.CurrentInputPort.Type.ToString() + }); } var snapshot = new RoutingSnapshotDto @@ -212,6 +232,8 @@ public class RoutingFeedbackWebsocket : IKeyed private void SubscribeToRoutingEvents() { + _tileChildren = RoutingGraphHelpers.BuildTileChildMap(); + var midpointDevices = DeviceManager.AllDevices.OfType(); foreach (var device in midpointDevices) { @@ -267,22 +289,31 @@ public class RoutingFeedbackWebsocket : IKeyed private void HandleSinkInputChanged(IRoutingSinkWithFeedback sender, RoutingInputPort currentInputPort) { + // Tile-sink children are reported under their IRoutingSinkWithLayouts parent's key, with a + // qualified port key, so clients see this as an input change on the parent's node rather than + // on a device that isn't otherwise represented in the graph. + var deviceKey = sender.Key; + var inputPortKey = currentInputPort?.Key ?? ""; + + if (_tileChildren.TryGetValue(deviceKey, out var tileInfo)) + { + deviceKey = tileInfo.Parent.Key; + if (!string.IsNullOrEmpty(inputPortKey)) + inputPortKey = RoutingGraphHelpers.QualifyTilePortKey(tileInfo.TileNumber, inputPortKey); + } + DebounceBroadcast($"sink-{sender.Key}", () => { - var sourceDeviceKey = ""; - if (currentInputPort != null) - { - var tieLine = TieLineCollection.Default.FirstOrDefault(tl => - tl.DestinationPort.Key == currentInputPort.Key && - tl.DestinationPort.ParentDevice.Key == currentInputPort.ParentDevice.Key); - sourceDeviceKey = tieLine?.SourcePort.ParentDevice.Key ?? ""; - } + // Read the source directly from the sink's own current-source bookkeeping (see + // GetCurrentSourceKey) rather than tracing a tie line - a route made via a + // device-specific bulk API (e.g. ApplyDynamicLayout) never creates a tie line at all. + var sourceDeviceKey = currentInputPort != null ? (RoutingGraphHelpers.GetCurrentSourceKey(sender) ?? "") : ""; var msg = new SinkInputChangedDto { Type = "sinkInputChanged", - DeviceKey = sender.Key, - InputPortKey = currentInputPort?.Key ?? "", + DeviceKey = deviceKey, + InputPortKey = inputPortKey, SourceDeviceKey = sourceDeviceKey, SignalType = currentInputPort?.Type.ToString() ?? "" }; @@ -360,7 +391,7 @@ public class RoutingFeedbackWebsocket : IKeyed { public string Type { get; set; } public Dictionary> MidpointRoutes { get; set; } - public Dictionary SinkRoutes { get; set; } + public Dictionary> SinkRoutes { get; set; } } private class MidpointRouteChangedDto diff --git a/src/PepperDash.Essentials.Core/Web/RoutingGraphHelpers.cs b/src/PepperDash.Essentials.Core/Web/RoutingGraphHelpers.cs new file mode 100644 index 00000000..714823ec --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RoutingGraphHelpers.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using PepperDash.Core; + +namespace PepperDash.Essentials.Core.Web; + +/// +/// Shared helpers for building routing-graph data for consumers of the routing dev tools +/// ( and +/// ). +/// +/// +/// Devices that implement (e.g. a multiview decoder) expose a +/// set of per-tile child sink devices (), each of +/// which is independently registered with and independently routable. +/// Rendered naively, each tile shows up as its own top-level node in the routing graph, which is +/// confusing - a multiview decoder with N tiles doesn't look like N separate devices to a user. +/// These helpers let routing-graph consumers instead: skip tile-sink children when enumerating +/// top-level devices, and represent each tile as a distinctly-keyed input "edge target" on the +/// parent's own node (see ), remapping any tie line / active route +/// that targets a tile so it points at the parent device instead. +/// +public static class RoutingGraphHelpers +{ + /// + /// Describes where a tile-sink child device () + /// belongs, for remapping it back to its parent's node in routing-graph output. + /// + public readonly struct TileChildInfo + { + /// The parent device this tile belongs to. + public IRoutingSinkWithLayouts Parent { get; } + + /// The tile's 1-based window number within the parent's layout. + public int TileNumber { get; } + + /// Initializes a new instance of the struct. + public TileChildInfo(IRoutingSinkWithLayouts parent, int tileNumber) + { + Parent = parent; + TileNumber = tileNumber; + } + } + + /// + /// Builds a map of every tile-sink child device key (across all + /// devices currently in ) to its parent device and tile number. + /// + public static Dictionary BuildTileChildMap() + { + var map = new Dictionary(); + + foreach (var parent in DeviceManager.AllDevices.OfType()) + { + foreach (var kvp in parent.WindowTileSinks) + { + if (kvp.Value is not IKeyed tile || string.IsNullOrEmpty(tile.Key)) + continue; + + map[tile.Key] = new TileChildInfo(parent, kvp.Key); + } + } + + return map; + } + + /// + /// Builds a graph-unique port key for a tile's port, so multiple tiles synthesized onto the same + /// parent node don't collide (every tile's own InputPorts collection typically contains a + /// single, identically-keyed port, e.g. "tileInput"). + /// + public static string QualifyTilePortKey(int tileNumber, string portKey) => $"tile{tileNumber}:{portKey}"; + + /// + /// Gets the device key of whatever source is currently feeding a sink's Video signal (falling back + /// to Audio), read directly from its own bookkeeping (part of + /// ). This is authoritative regardless of whether the route + /// was made via a tie line (ReleaseAndMakeRoute) or a device-specific bulk API (e.g. + /// IHasDynamicMultiviewLayout.ApplyDynamicLayout) that never touches + /// TieLineCollection/RouteDescriptorCollection at all. + /// + public static string GetCurrentSourceKey(IRoutingSinkWithFeedback device) + { + if (device.CurrentSourceKeys.TryGetValue(eRoutingSignalType.Video, out var videoKey) && !string.IsNullOrEmpty(videoKey)) + return videoKey; + + return device.CurrentSourceKeys.TryGetValue(eRoutingSignalType.Audio, out var audioKey) ? audioKey : null; + } +} diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index bc50316c..ed5bdde8 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -1305,7 +1305,16 @@ namespace PepperDash.Essentials.WebSocketServer } byte[] contents = File.ReadAllBytes(filePath); res.ContentLength64 = contents.LongLength; - res.Close(contents, true); + try + { + res.Close(contents, true); + } + catch (IOException ex) + { + // Client disconnected (e.g. panel refreshed/navigated away) before the response could be + // fully written. This is a benign, expected condition, not an application error. + this.LogVerbose("Client disconnected before image response could be sent: {message}", ex.Message); + } } else { @@ -1427,7 +1436,17 @@ namespace PepperDash.Essentials.WebSocketServer } res.ContentLength64 = contents.LongLength; - res.Close(contents, true); + try + { + res.Close(contents, true); + } + catch (IOException ex) + { + // Client disconnected (e.g. panel refreshed/navigated away, or made a duplicate request) + // before the response could be fully written. This is a benign, expected condition + // (e.g. a "Broken pipe" IOException), not an application error worth logging at Error level. + this.LogVerbose("Client disconnected before user app response could be sent: {message}", ex.Message); + } } ///