feat: enhance routing graph handling for tile-sink children and add RoutingGraphHelpers utility class

This commit is contained in:
Neil Dorin 2026-07-14 19:34:14 -06:00
parent 7d1e0432cb
commit 079f2beb8b
4 changed files with 335 additions and 44 deletions

View file

@ -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<RoutingDeviceInfo>();
// 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();
}
/// <summary>
/// Builds current-source info for every sink device, read directly from each sink's own
/// <see cref="ICurrentSources"/> bookkeeping (see <see cref="RoutingGraphHelpers.GetCurrentSourceKey"/>).
/// Unlike <see cref="RouteDescriptorCollection"/>-based <see cref="CurrentRouteGroupInfo"/>, this
/// also reflects routes made via a device-specific bulk API (e.g.
/// <c>IHasDynamicMultiviewLayout.ApplyDynamicLayout</c>) that never creates a
/// <see cref="RouteDescriptor"/> or <see cref="TieLine"/> at all, so it's what the dev tools app
/// should use to seed initial sink-routing state on page load.
/// </summary>
private static List<SinkCurrentSourceInfo> BuildSinkCurrentSources(
Dictionary<string, RoutingGraphHelpers.TileChildInfo> tileChildren)
{
var result = new List<SinkCurrentSourceInfo>();
foreach (var device in DeviceManager.AllDevices.OfType<IRoutingSinkWithFeedback>())
{
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;
}
}
/// <summary>
@ -148,6 +262,44 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
/// </summary>
[JsonProperty("currentRoutes")]
public List<CurrentRouteGroupInfo> CurrentRoutes { get; set; }
/// <summary>
/// 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 <see cref="CurrentRoutes"/> does not.
/// </summary>
[JsonProperty("sinkCurrentSources")]
public List<SinkCurrentSourceInfo> SinkCurrentSources { get; set; }
}
/// <summary>
/// Represents the source currently feeding a single sink device's input port
/// </summary>
public class SinkCurrentSourceInfo
{
/// <summary>
/// Gets or sets the key of the sink device (or its IRoutingSinkWithLayouts parent, if this is a tile)
/// </summary>
[JsonProperty("deviceKey")]
public string DeviceKey { get; set; }
/// <summary>
/// Gets or sets the key of the input port currently receiving the source
/// </summary>
[JsonProperty("inputPortKey")]
public string InputPortKey { get; set; }
/// <summary>
/// Gets or sets the key of the device currently feeding this input
/// </summary>
[JsonProperty("sourceDeviceKey")]
public string SourceDeviceKey { get; set; }
/// <summary>
/// Gets or sets the signal type of the input port (e.g., AudioVideo, Audio, Video, etc.)
/// </summary>
[JsonProperty("signalType")]
public string SignalType { get; set; }
}
/// <summary>

View file

@ -33,6 +33,11 @@ public class RoutingFeedbackWebsocket : IKeyed
private readonly Dictionary<string, Timer> _debounceTimers = new Dictionary<string, Timer>();
// 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<string, RoutingGraphHelpers.TileChildInfo> _tileChildren = new Dictionary<string, RoutingGraphHelpers.TileChildInfo>();
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<string, List<MidpointRouteDto>>();
var sinkRoutes = new Dictionary<string, SinkRouteDto>();
var sinkRoutes = new Dictionary<string, List<SinkRouteDto>>();
// Collect midpoint current routes
var midpointDevices = DeviceManager.AllDevices.OfType<IRoutingMidpointWithFeedback>();
@ -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<IRoutingSinkWithFeedback>();
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<SinkRouteDto>();
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<IRoutingMidpointWithFeedback>();
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<string, List<MidpointRouteDto>> MidpointRoutes { get; set; }
public Dictionary<string, SinkRouteDto> SinkRoutes { get; set; }
public Dictionary<string, List<SinkRouteDto>> SinkRoutes { get; set; }
}
private class MidpointRouteChangedDto

View file

@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Linq;
using PepperDash.Core;
namespace PepperDash.Essentials.Core.Web;
/// <summary>
/// Shared helpers for building routing-graph data for consumers of the routing dev tools
/// (<see cref="RequestHandlers.GetRoutingDevicesAndTieLinesHandler"/> and
/// <see cref="RoutingFeedbackWebsocket"/>).
/// </summary>
/// <remarks>
/// Devices that implement <see cref="IRoutingSinkWithLayouts"/> (e.g. a multiview decoder) expose a
/// set of per-tile child sink devices (<see cref="IRoutingSinkWithLayouts.WindowTileSinks"/>), each of
/// which is independently registered with <see cref="DeviceManager"/> 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 <see cref="QualifyTilePortKey"/>), remapping any tie line / active route
/// that targets a tile so it points at the parent device instead.
/// </remarks>
public static class RoutingGraphHelpers
{
/// <summary>
/// Describes where a tile-sink child device (<see cref="IRoutingSinkWithLayouts.WindowTileSinks"/>)
/// belongs, for remapping it back to its parent's node in routing-graph output.
/// </summary>
public readonly struct TileChildInfo
{
/// <summary>The parent device this tile belongs to.</summary>
public IRoutingSinkWithLayouts Parent { get; }
/// <summary>The tile's 1-based window number within the parent's layout.</summary>
public int TileNumber { get; }
/// <summary>Initializes a new instance of the <see cref="TileChildInfo"/> struct.</summary>
public TileChildInfo(IRoutingSinkWithLayouts parent, int tileNumber)
{
Parent = parent;
TileNumber = tileNumber;
}
}
/// <summary>
/// Builds a map of every tile-sink child device key (across all <see cref="IRoutingSinkWithLayouts"/>
/// devices currently in <see cref="DeviceManager"/>) to its parent device and tile number.
/// </summary>
public static Dictionary<string, TileChildInfo> BuildTileChildMap()
{
var map = new Dictionary<string, TileChildInfo>();
foreach (var parent in DeviceManager.AllDevices.OfType<IRoutingSinkWithLayouts>())
{
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;
}
/// <summary>
/// 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 <c>InputPorts</c> collection typically contains a
/// single, identically-keyed port, e.g. "tileInput").
/// </summary>
public static string QualifyTilePortKey(int tileNumber, string portKey) => $"tile{tileNumber}:{portKey}";
/// <summary>
/// Gets the device key of whatever source is currently feeding a sink's Video signal (falling back
/// to Audio), read directly from its own <see cref="ICurrentSources"/> bookkeeping (part of
/// <see cref="IRoutingSinkWithFeedback"/>). This is authoritative regardless of whether the route
/// was made via a tie line (<c>ReleaseAndMakeRoute</c>) or a device-specific bulk API (e.g.
/// <c>IHasDynamicMultiviewLayout.ApplyDynamicLayout</c>) that never touches
/// <c>TieLineCollection</c>/<c>RouteDescriptorCollection</c> at all.
/// </summary>
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;
}
}

View file

@ -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);
}
}
/// <summary>