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
This commit is contained in:
Andrew Welker 2026-01-14 10:00:48 -06:00
parent d05ebecd7d
commit fb8216beed
5 changed files with 621 additions and 248 deletions

View file

@ -18,6 +18,20 @@ namespace PepperDash.Essentials.Core
/// </summary>
public static class Extensions
{
/// <summary>
/// A collection of RouteDescriptors for each signal type.
/// </summary>
public static readonly Dictionary<eRoutingSignalType, RouteDescriptorCollection> RouteDescriptors = new Dictionary<eRoutingSignalType, RouteDescriptorCollection>()
{
{ eRoutingSignalType.Audio, new RouteDescriptorCollection() },
{ eRoutingSignalType.Video, new RouteDescriptorCollection() },
{ eRoutingSignalType.SecondaryAudio, new RouteDescriptorCollection() },
{ eRoutingSignalType.AudioVideo, new RouteDescriptorCollection() },
{ eRoutingSignalType.UsbInput, new RouteDescriptorCollection() },
{ eRoutingSignalType.UsbOutput, new RouteDescriptorCollection() }
};
/// <summary>
/// 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
/// </summary>
private static readonly GenericQueue routeRequestQueue = new GenericQueue("routingQueue");
/// <summary>
/// Indexed lookup of TieLines by destination device key for faster queries.
/// </summary>
private static Dictionary<string, List<TieLine>> _tieLinesByDestination;
/// <summary>
/// Indexed lookup of TieLines by source device key for faster queries.
/// </summary>
private static Dictionary<string, List<TieLine>> _tieLinesBySource;
/// <summary>
/// Cache of failed route attempts to avoid re-checking impossible paths.
/// Format: "sourceKey|destKey|signalType"
/// </summary>
private static readonly HashSet<string> _impossibleRoutes = new HashSet<string>();
/// <summary>
/// Indexes all TieLines by source and destination device keys for faster lookups.
/// Should be called once at system startup after all TieLines are created.
/// </summary>
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: ");
}
}
/// <summary>
/// Gets TieLines connected to a destination device.
/// Uses indexed lookup if available, otherwise falls back to LINQ query.
/// </summary>
/// <param name="destinationKey">The destination device key</param>
/// <returns>List of TieLines connected to the destination</returns>
private static IEnumerable<TieLine> GetTieLinesForDestination(string destinationKey)
{
if (_tieLinesByDestination != null && _tieLinesByDestination.TryGetValue(destinationKey, out List<TieLine> tieLines))
{
return tieLines;
}
// Fallback to LINQ if index not available
return TieLineCollection.Default.Where(t => t.DestinationPort.ParentDevice.Key == destinationKey);
}
/// <summary>
/// Gets TieLines connected to a source device.
/// Uses indexed lookup if available, otherwise falls back to LINQ query.
/// </summary>
/// <param name="sourceKey">The source device key</param>
/// <returns>List of TieLines connected to the source</returns>
private static IEnumerable<TieLine> GetTieLinesForSource(string sourceKey)
{
if (_tieLinesBySource != null && _tieLinesBySource.TryGetValue(sourceKey, out List<TieLine> tieLines))
{
return tieLines;
}
// Fallback to LINQ if index not available
return TieLineCollection.Default.Where(t => t.SourcePort.ParentDevice.Key == sourceKey);
}
/// <summary>
/// Creates a cache key for route impossibility tracking.
/// </summary>
/// <param name="sourceKey">Source device key</param>
/// <param name="destKey">Destination device key</param>
/// <param name="type">Signal type</param>
/// <returns>Cache key string</returns>
private static string GetRouteKey(string sourceKey, string destKey, eRoutingSignalType type)
{
return string.Format("{0}|{1}|{2}", sourceKey, destKey, type);
}
/// <summary>
/// Clears the impossible routes cache. Should be called if TieLines are added/removed at runtime.
/// </summary>
public static void ClearImpossibleRoutesCache()
{
_impossibleRoutes.Clear();
Debug.LogMessage(LogEventLevel.Information, "Impossible routes cache cleared");
}
/// <summary>
/// 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);
}
/// <summary>
@ -245,6 +359,90 @@ namespace PepperDash.Essentials.Core
routeRequestQueue.Enqueue(new RouteRequestQueueItem(RunRouteRequest, routeRequest));
}
/// <summary>
/// Maps destination input ports to source output ports for all routing devices.
/// </summary>
public static void MapDestinationsToSources()
{
try
{
// Index TieLines before mapping if not already done
if (_tieLinesByDestination == null || _tieLinesBySource == null)
{
IndexTieLines();
}
var sinks = DeviceManager.AllDevices.OfType<IRoutingInputs>().Where(d => !(d is IRoutingInputsOutputs));
var sources = DeviceManager.AllDevices.OfType<IRoutingOutputs>().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: ");
}
}
/// <summary>
/// Executes the actual routing based on a <see cref="RouteRequest"/>.
/// 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
/// </summary>
/// <param name="destination"></param>
/// <param name="source"></param>
/// <param name="destinationPort">The RoutingOutputPort whose link is being checked for a route</param>
/// <param name="outputPortToUse">The RoutingOutputPort whose link is being checked for a route</param>
/// <param name="alreadyCheckedDevices">Prevents Devices from being twice-checked</param>
/// <param name="signalType">This recursive function should not be called with AudioVideo</param>
/// <param name="cycle">Just an informational counter</param>
/// <param name="routeTable">The RouteDescriptor being populated as the route is discovered</param>
/// <param name="destinationPort">The RoutingOutputPort whose link is being checked for a route</param>
/// <param name="sourcePort">The source output port (optional)</param>
/// <returns>true if source is hit</returns>
private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOutputs source,
RoutingOutputPort outputPortToUse, List<IRoutingInputsOutputs> 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<TieLine> 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;
}