feat: implement Routing Feedback WebSocket service and associated request handlers

This commit is contained in:
Neil Dorin 2026-06-22 21:30:47 -06:00
parent be545bbfa4
commit b65f895e06
7 changed files with 812 additions and 5 deletions

View file

@ -75,6 +75,20 @@ public class DebugWebsocketSink : ILogEventSink, IKeyed
/// </summary>
public bool IsRunning { get => _httpsServer?.IsListening ?? false; }
/// <summary>
/// Gets a value indicating whether there are active WebSocket connections.
/// </summary>
public bool HasActiveConnections
{
get
{
if (_httpsServer == null || !_httpsServer.IsListening) return false;
var service = _httpsServer.WebSocketServices[_path];
if (service == null) return false;
return service.Sessions.Count > 0;
}
}
/// <inheritdoc/>
public string Key => "DebugWebsocketSink";

View file

@ -61,10 +61,10 @@ public interface IRunDirectRouteAction
/// <summary>
/// Runs a direct route from a source to a destination with an optional signal type for routing.
/// </summary>
/// <param name="sourceKey"></param>
/// <param name="destinationKey"></param>
/// <param name="source"></param>
/// <param name="destination"></param>
/// <param name="type"></param>
void RunDirectRoute(string sourceKey, string destinationKey, eRoutingSignalType type = eRoutingSignalType.AudioVideo);
void RunDirectRoute(string source, string destination, eRoutingSignalType type = eRoutingSignalType.AudioVideo);
}
/// <summary>

View file

@ -36,6 +36,7 @@ namespace PepperDash.Essentials.Core.Routing
/// This method updates the current source for the specified signal type and notifies any subscribers of the change.
/// </summary>
/// <param name="signalType">The signal type to update.</param>
///
/// <param name="sourceDevice">The source device to set as the current source.</param>
void SetCurrentSource(eRoutingSignalType signalType, IRoutingSource sourceDevice);

View file

@ -190,6 +190,11 @@ public class EssentialsWebApi : EssentialsDevice
Name = "Get Routing Devices and TieLines",
RouteHandler = new GetRoutingDevicesAndTieLinesHandler()
},
new HttpCwsRoute("routingFeedbackSession")
{
Name = "Routing Feedback WebSocket Session",
RouteHandler = new RoutingFeedbackSessionRequestHandler()
},
new HttpCwsRoute("initializationExceptions")
{
Name = "Get Initialization Exceptions",

View file

@ -15,6 +15,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers;
/// </summary>
public class DebugSessionRequestHandler : WebApiBaseRequestHandler
{
private CTimer _portForwardTimeoutTimer;
private readonly object _timerLock = new object();
/// <summary>
/// Constructor
@ -45,6 +47,7 @@ public class DebugSessionRequestHandler : WebApiBaseRequestHandler
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
var port = 0;
string csIp = null;
if (!Debug.WebsocketSink.IsRunning)
{
@ -56,6 +59,40 @@ public class DebugSessionRequestHandler : WebApiBaseRequestHandler
Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose);
}
// Attempt to get the CS LAN IP and forward the port
try
{
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
EthernetAdapterType.EthernetCSAdapter);
csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
if (port > 0)
{
var result = CrestronEthernetHelper.AddPortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error forwarding port {0} to CS LAN: {1}", port, result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port);
StartPortForwardTimeout(port, csIp);
}
}
}
catch (ArgumentException)
{
Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding");
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error automatically forwarding debug websocket port to CS LAN: {0}", ex.Message);
}
if (!Debug.WebsocketSink.IsRunning)
{
context.Response.StatusCode = 500;
@ -67,12 +104,14 @@ public class DebugSessionRequestHandler : WebApiBaseRequestHandler
var url = Debug.WebsocketSink.Url;
object data = new
var data = new
{
url = Debug.WebsocketSink.Url
url = Debug.WebsocketSink.Url,
fallbackUrl = csIp != null ? url.Replace(csIp, ip) : null
};
Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url);
Debug.LogMessage(LogEventLevel.Information, "Fallback Debug Session URL: {0}", data.fallbackUrl);
// Return the port number with the full url of the WS Server
var res = JsonConvert.SerializeObject(data);
@ -96,9 +135,42 @@ public class DebugSessionRequestHandler : WebApiBaseRequestHandler
/// <param name="context"></param>
protected override void HandlePost(HttpCwsContext context)
{
CancelPortForwardTimeout();
var port = Debug.WebsocketSink.Port;
Task.Run(() => Debug.WebsocketSink.StopServer());
// Remove port forwarding if CS LAN exists
try
{
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
EthernetAdapterType.EthernetCSAdapter);
var csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
var result = CrestronEthernetHelper.RemovePortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding for port {0}: {1}", port, result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed", port);
}
}
catch (ArgumentException)
{
// No CS LAN adapter
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding: {0}", ex.Message);
}
context.Response.StatusCode = 200;
context.Response.StatusDescription = "OK";
context.Response.End();
@ -106,4 +178,53 @@ public class DebugSessionRequestHandler : WebApiBaseRequestHandler
Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped");
}
private void StartPortForwardTimeout(int port, string csIp)
{
lock (_timerLock)
{
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = new CTimer(_ =>
{
if (Debug.WebsocketSink.HasActiveConnections)
{
Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward");
return;
}
Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port);
try
{
var result = CrestronEthernetHelper.RemovePortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed due to timeout", port);
}
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", ex.Message);
}
}, 30000);
}
}
/// <summary>
/// Cancels the port forward timeout timer if a session is being explicitly stopped.
/// </summary>
private void CancelPortForwardTimeout()
{
lock (_timerLock)
{
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = null;
}
}
}

View file

@ -0,0 +1,233 @@
using System;
using System.Text;
using Crestron.SimplSharp;
using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json;
using PepperDash.Core;
using PepperDash.Core.Web.RequestHandlers;
using Serilog.Events;
namespace PepperDash.Essentials.Core.Web.RequestHandlers;
/// <summary>
/// Handles HTTP requests to start and retrieve the routing feedback WebSocket session URL.
/// GET returns the current URL (starting the server if necessary).
/// POST stops the server.
/// </summary>
public class RoutingFeedbackSessionRequestHandler : WebApiBaseRequestHandler
{
private static readonly RoutingFeedbackWebsocket _instance = new RoutingFeedbackWebsocket();
private CTimer _portForwardTimeoutTimer;
private readonly object _timerLock = new object();
/// <summary>
/// Gets the singleton RoutingFeedbackWebsocket instance.
/// </summary>
public static RoutingFeedbackWebsocket Instance => _instance;
/// <summary>
/// Constructor
/// </summary>
public RoutingFeedbackSessionRequestHandler()
: base(true)
{
}
/// <summary>
/// Handles GET requests: starts the routing feedback WebSocket if not running and returns the URL.
/// </summary>
protected override void HandleGet(HttpCwsContext context)
{
try
{
var ip = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
var port = 0;
string csIp = null;
if (!_instance.IsRunning)
{
Debug.LogMessage(LogEventLevel.Information, "Starting Routing Feedback WS Server");
port = new Random().Next(65335, 65434);
_instance.StartServerAndSetPort(port);
}
// Attempt to get the CS LAN IP and forward the port
try
{
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
EthernetAdapterType.EthernetCSAdapter);
csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
if (port > 0)
{
var result = CrestronEthernetHelper.AddPortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error forwarding port {0} to CS LAN: {1}", port, result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for routing feedback websocket", port);
StartPortForwardTimeout(port, csIp);
}
}
}
catch (ArgumentException)
{
Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding");
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error automatically forwarding routing feedback websocket port to CS LAN: {0}", ex.Message);
}
if (!_instance.IsRunning)
{
context.Response.StatusCode = 500;
context.Response.StatusDescription = "Internal Server Error";
context.Response.ContentType = "application/json";
context.Response.ContentEncoding = Encoding.UTF8;
context.Response.Write(
JsonConvert.SerializeObject(new { error = "Failed to start routing feedback WebSocket server." }),
false);
context.Response.End();
return;
}
var url = _instance.Url;
var data = new
{
url,
fallbackUrl = csIp != null ? url.Replace(csIp, ip) : null
};
Debug.LogMessage(LogEventLevel.Information, "Routing Feedback Session URL: {0}", url);
Debug.LogMessage(LogEventLevel.Information, "Fallback Routing Feedback Session URL: {0}", data.fallbackUrl);
var json = JsonConvert.SerializeObject(data);
context.Response.StatusCode = 200;
context.Response.StatusDescription = "OK";
context.Response.ContentType = "application/json";
context.Response.ContentEncoding = Encoding.UTF8;
context.Response.Write(json, false);
context.Response.End();
}
catch (Exception ex)
{
Debug.LogError(ex, "Error handling routing feedback session GET: {message}", ex.Message);
context.Response.StatusCode = 500;
context.Response.StatusDescription = "Internal Server Error";
context.Response.End();
}
}
/// <summary>
/// Handles POST requests: stops the routing feedback WebSocket server.
/// </summary>
protected override void HandlePost(HttpCwsContext context)
{
try
{
CancelPortForwardTimeout();
var port = _instance.Port;
_instance.StopServer();
// Remove port forwarding if CS LAN exists
try
{
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
EthernetAdapterType.EthernetCSAdapter);
var csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
var result = CrestronEthernetHelper.RemovePortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding for port {0}: {1}", port, result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed", port);
}
}
catch (ArgumentException)
{
// No CS LAN adapter
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding: {0}", ex.Message);
}
context.Response.StatusCode = 200;
context.Response.StatusDescription = "OK";
context.Response.End();
}
catch (Exception ex)
{
Debug.LogError(ex, "Error handling routing feedback session POST: {message}", ex.Message);
context.Response.StatusCode = 500;
context.Response.StatusDescription = "Internal Server Error";
context.Response.End();
}
}
private void StartPortForwardTimeout(int port, string csIp)
{
lock (_timerLock)
{
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = new CTimer(_ =>
{
if (_instance.HasActiveConnections)
{
Debug.LogMessage(LogEventLevel.Debug, "Routing feedback websocket has active connections; keeping port forward");
return;
}
Debug.LogMessage(LogEventLevel.Information, "No routing feedback websocket connection within 30 seconds; removing port forward for port {0}", port);
try
{
var result = CrestronEthernetHelper.RemovePortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed due to timeout", port);
}
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", ex.Message);
}
}, 30000);
}
}
private void CancelPortForwardTimeout()
{
lock (_timerLock)
{
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = null;
}
}
}

View file

@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Timers;
using Crestron.SimplSharp;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using PepperDash.Core;
using Serilog.Events;
using WebSocketSharp;
using WebSocketSharp.Server;
namespace PepperDash.Essentials.Core.Web;
/// <summary>
/// WebSocket service that broadcasts real-time routing state changes to connected clients.
/// Subscribes to route-changed events on midpoint and sink devices and pushes updates
/// to all connected WebSocket clients.
/// </summary>
public class RoutingFeedbackWebsocket : IKeyed
{
private HttpServer _httpsServer;
private readonly string _path = "/routing/join/";
private const string _certificateName = "selfCres";
private const string _certificatePassword = "cres12345";
private const long DEBOUNCE_MS = 200;
private static string CertPath =>
$"{Path.DirectorySeparatorChar}user{Path.DirectorySeparatorChar}{_certificateName}.pfx";
private readonly Dictionary<string, Timer> _debounceTimers = new Dictionary<string, Timer>();
private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
};
/// <inheritdoc/>
public string Key => "RoutingFeedbackWebsocket";
/// <summary>
/// Gets the port number on which the server is currently running.
/// </summary>
public int Port => _httpsServer?.Port ?? 0;
/// <summary>
/// Gets the WebSocket URL for the current server instance.
/// </summary>
public string Url
{
get
{
if (_httpsServer == null || !_httpsServer.IsListening) return "";
var service = _httpsServer.WebSocketServices[_path];
if (service == null) return "";
var ip = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1);
if (string.IsNullOrEmpty(ip))
ip = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
return $"wss://{ip}:{_httpsServer.Port}{service.Path}";
}
}
/// <summary>
/// Gets a value indicating whether the server is currently listening.
/// </summary>
public bool IsRunning => _httpsServer?.IsListening ?? false;
/// <summary>
/// Gets a value indicating whether there are active WebSocket connections.
/// </summary>
public bool HasActiveConnections
{
get
{
if (_httpsServer == null || !_httpsServer.IsListening) return false;
var service = _httpsServer.WebSocketServices[_path];
if (service == null) return false;
return service.Sessions.Count > 0;
}
}
/// <summary>
/// Starts the WebSocket server on the specified port and subscribes to routing events.
/// </summary>
/// <param name="port">The port to listen on.</param>
public void StartServerAndSetPort(int port)
{
if (IsRunning)
{
Debug.LogMessage(LogEventLevel.Information, "Routing feedback WebSocket already running on port {port}", this, Port);
return;
}
Debug.LogMessage(LogEventLevel.Information, "Starting Routing Feedback WebSocket on port: {port}", this, port);
try
{
_httpsServer = new HttpServer(port, true);
var cert = LoadCert(CertPath, _certificatePassword);
_httpsServer.SslConfiguration.ServerCertificate = cert;
_httpsServer.SslConfiguration.ClientCertificateRequired = false;
_httpsServer.SslConfiguration.CheckCertificateRevocation = false;
_httpsServer.SslConfiguration.EnabledSslProtocols = SslProtocols.Tls12;
_httpsServer.SslConfiguration.ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true;
_httpsServer.AddWebSocketService<RoutingFeedbackClient>(_path, () => new RoutingFeedbackClient(this));
_httpsServer.Log.Level = LogLevel.Warn;
_httpsServer.Start();
SubscribeToRoutingEvents();
Debug.LogMessage(LogEventLevel.Information, "Routing Feedback WebSocket ready at {url}", this, Url);
}
catch (Exception ex)
{
Debug.LogError(ex, "Routing Feedback WebSocket failed to start: {message}", this, ex.Message);
_httpsServer = null;
}
}
/// <summary>
/// Stops the WebSocket server and unsubscribes from routing events.
/// </summary>
public void StopServer()
{
UnsubscribeFromRoutingEvents();
try
{
if (_httpsServer == null || !_httpsServer.IsListening)
return;
_httpsServer.Log.Output = (d, s) => { };
_httpsServer.Stop();
_httpsServer = null;
}
catch (Exception ex)
{
Debug.LogError(ex, "Routing Feedback WebSocket failed to stop: {message}", this, ex.Message);
}
}
/// <summary>
/// Builds and returns the full current routing state snapshot to send to newly connected clients.
/// </summary>
internal string GetSnapshotMessage()
{
var midpointRoutes = new Dictionary<string, List<MidpointRouteDto>>();
var sinkRoutes = new Dictionary<string, SinkRouteDto>();
// Collect midpoint current routes
var midpointDevices = DeviceManager.AllDevices.OfType<IRoutingMidpointWithFeedback>();
foreach (var device in midpointDevices)
{
if (device.CurrentRoutes == null || device.CurrentRoutes.Count == 0)
continue;
midpointRoutes[device.Key] = device.CurrentRoutes
.Where(r => r.InputPort != null)
.Select(r => new MidpointRouteDto
{
InputPortKey = r.InputPort.Key,
OutputPortKey = r.OutputPort?.Key,
SignalType = r.InputPort.Type.ToString()
})
.ToList();
}
// Collect sink current sources
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);
if (tieLine != null)
{
sinkRoutes[device.Key] = new SinkRouteDto
{
InputPortKey = device.CurrentInputPort.Key,
SourceDeviceKey = tieLine.SourcePort.ParentDevice.Key,
SignalType = device.CurrentInputPort.Type.ToString()
};
}
}
var snapshot = new RoutingSnapshotDto
{
Type = "snapshot",
MidpointRoutes = midpointRoutes,
SinkRoutes = sinkRoutes
};
return JsonConvert.SerializeObject(snapshot, JsonSettings);
}
private void SubscribeToRoutingEvents()
{
var midpointDevices = DeviceManager.AllDevices.OfType<IRoutingMidpointWithFeedback>();
foreach (var device in midpointDevices)
{
device.RouteChanged += HandleMidpointRouteChanged;
}
var sinkDevices = DeviceManager.AllDevices.OfType<IRoutingSinkWithFeedback>();
foreach (var device in sinkDevices)
{
device.InputChanged += HandleSinkInputChanged;
}
}
private void UnsubscribeFromRoutingEvents()
{
var midpointDevices = DeviceManager.AllDevices.OfType<IRoutingMidpointWithFeedback>();
foreach (var device in midpointDevices)
{
device.RouteChanged -= HandleMidpointRouteChanged;
}
var sinkDevices = DeviceManager.AllDevices.OfType<IRoutingSinkWithFeedback>();
foreach (var device in sinkDevices)
{
device.InputChanged -= HandleSinkInputChanged;
}
}
private void HandleMidpointRouteChanged(IRoutingMidpointWithFeedback midpoint, RouteSwitchDescriptor newRoute)
{
DebounceBroadcast($"midpoint-{midpoint.Key}", () =>
{
var routes = midpoint.CurrentRoutes?
.Where(r => r.InputPort != null)
.Select(r => new MidpointRouteDto
{
InputPortKey = r.InputPort.Key,
OutputPortKey = r.OutputPort?.Key,
SignalType = r.InputPort.Type.ToString()
})
.ToList() ?? new List<MidpointRouteDto>();
var msg = new MidpointRouteChangedDto
{
Type = "midpointRouteChanged",
DeviceKey = midpoint.Key,
Routes = routes
};
Broadcast(JsonConvert.SerializeObject(msg, JsonSettings));
});
}
private void HandleSinkInputChanged(IRoutingSinkWithFeedback sender, RoutingInputPort currentInputPort)
{
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 ?? "";
}
var msg = new SinkInputChangedDto
{
Type = "sinkInputChanged",
DeviceKey = sender.Key,
InputPortKey = currentInputPort?.Key ?? "",
SourceDeviceKey = sourceDeviceKey,
SignalType = currentInputPort?.Type.ToString() ?? ""
};
Broadcast(JsonConvert.SerializeObject(msg, JsonSettings));
});
}
private void DebounceBroadcast(string key, Action action)
{
lock (_debounceTimers)
{
if (_debounceTimers.TryGetValue(key, out var existingTimer))
{
existingTimer.Stop();
existingTimer.Dispose();
}
var timer = new Timer(DEBOUNCE_MS) { AutoReset = false };
timer.Elapsed += (s, e) =>
{
try
{
action();
}
catch (Exception ex)
{
Debug.LogError(ex, "Error in debounced routing broadcast for {key}: {message}", this, key, ex.Message);
}
finally
{
lock (_debounceTimers)
{
if (_debounceTimers.ContainsKey(key))
{
_debounceTimers[key]?.Dispose();
_debounceTimers.Remove(key);
}
}
}
};
timer.Start();
_debounceTimers[key] = timer;
}
}
private void Broadcast(string message)
{
if (_httpsServer == null || !_httpsServer.IsListening) return;
var service = _httpsServer.WebSocketServices[_path];
if (service == null) return;
service.Sessions.Broadcast(message);
}
private static X509Certificate2 LoadCert(string certPath, string certPassword)
{
return new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet);
}
// ── DTOs ─────────────────────────────────────────────────────────────────
private class RoutingSnapshotDto
{
public string Type { get; set; }
public Dictionary<string, List<MidpointRouteDto>> MidpointRoutes { get; set; }
public Dictionary<string, SinkRouteDto> SinkRoutes { get; set; }
}
private class MidpointRouteChangedDto
{
public string Type { get; set; }
public string DeviceKey { get; set; }
public List<MidpointRouteDto> Routes { get; set; }
}
private class SinkInputChangedDto
{
public string Type { get; set; }
public string DeviceKey { get; set; }
public string InputPortKey { get; set; }
public string SourceDeviceKey { get; set; }
public string SignalType { get; set; }
}
private class MidpointRouteDto
{
public string InputPortKey { get; set; }
public string OutputPortKey { get; set; }
public string SignalType { get; set; }
}
private class SinkRouteDto
{
public string InputPortKey { get; set; }
public string SourceDeviceKey { get; set; }
public string SignalType { get; set; }
}
}
/// <summary>
/// WebSocket client behavior for routing feedback connections.
/// Sends a full state snapshot on connect.
/// </summary>
public class RoutingFeedbackClient : WebSocketBehavior
{
private readonly RoutingFeedbackWebsocket _owner;
/// <summary>
/// Initializes a new instance of the <see cref="RoutingFeedbackClient"/> class.
/// </summary>
/// <param name="owner">The owning <see cref="RoutingFeedbackWebsocket"/> instance.</param>
public RoutingFeedbackClient(RoutingFeedbackWebsocket owner)
{
_owner = owner;
}
/// <inheritdoc/>
protected override void OnOpen()
{
base.OnOpen();
Debug.LogMessage(LogEventLevel.Information, "Routing feedback client connected from: {url}", _owner, Context.WebSocket.Url);
// Send full state snapshot to the newly connected client
try
{
var snapshot = _owner.GetSnapshotMessage();
Send(snapshot);
}
catch (Exception ex)
{
Debug.LogError(ex, "Error sending routing snapshot to client: {message}", _owner, ex.Message);
}
}
/// <inheritdoc/>
protected override void OnClose(CloseEventArgs e)
{
base.OnClose(e);
Debug.LogMessage(LogEventLevel.Debug, "Routing feedback client disconnected: {code} {reason}", _owner, e.Code, e.Reason);
}
/// <inheritdoc/>
protected override void OnError(WebSocketSharp.ErrorEventArgs e)
{
base.OnError(e);
Debug.LogError(e.Exception, "Routing feedback client error: {message}", _owner, e.Message);
}
}