From b65f895e06883c36f01ef3ea016e832da0fe06e5 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 22 Jun 2026 21:30:47 -0600 Subject: [PATCH] feat: implement Routing Feedback WebSocket service and associated request handlers --- .../Logging/DebugWebsocketSink.cs | 14 + .../Room/Interfaces.cs | 6 +- .../Routing/ICurrentSources.cs | 1 + .../Web/EssentialsWebApi.cs | 5 + .../DebugSessionRequestHandler.cs | 125 ++++- .../RoutingFeedbackSessionRequestHandler.cs | 233 ++++++++++ .../Web/RoutingFeedbackWebsocket.cs | 433 ++++++++++++++++++ 7 files changed, 812 insertions(+), 5 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/Web/RequestHandlers/RoutingFeedbackSessionRequestHandler.cs create mode 100644 src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 6214505b..3027640f 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -75,6 +75,20 @@ public class DebugWebsocketSink : ILogEventSink, IKeyed /// public bool IsRunning { get => _httpsServer?.IsListening ?? false; } + /// + /// Gets a value indicating whether there are active WebSocket connections. + /// + 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; + } + } + /// public string Key => "DebugWebsocketSink"; diff --git a/src/PepperDash.Essentials.Core/Room/Interfaces.cs b/src/PepperDash.Essentials.Core/Room/Interfaces.cs index 5d3c704c..499e66a4 100644 --- a/src/PepperDash.Essentials.Core/Room/Interfaces.cs +++ b/src/PepperDash.Essentials.Core/Room/Interfaces.cs @@ -61,10 +61,10 @@ public interface IRunDirectRouteAction /// /// Runs a direct route from a source to a destination with an optional signal type for routing. /// - /// - /// + /// + /// /// - void RunDirectRoute(string sourceKey, string destinationKey, eRoutingSignalType type = eRoutingSignalType.AudioVideo); + void RunDirectRoute(string source, string destination, eRoutingSignalType type = eRoutingSignalType.AudioVideo); } /// diff --git a/src/PepperDash.Essentials.Core/Routing/ICurrentSources.cs b/src/PepperDash.Essentials.Core/Routing/ICurrentSources.cs index eb575815..4222bb4d 100644 --- a/src/PepperDash.Essentials.Core/Routing/ICurrentSources.cs +++ b/src/PepperDash.Essentials.Core/Routing/ICurrentSources.cs @@ -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. /// /// The signal type to update. + /// /// The source device to set as the current source. void SetCurrentSource(eRoutingSignalType signalType, IRoutingSource sourceDevice); diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index fe1a448c..86f01803 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -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", diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index c0dee8bb..af4e3d38 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -15,6 +15,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers; /// public class DebugSessionRequestHandler : WebApiBaseRequestHandler { + private CTimer _portForwardTimeoutTimer; + private readonly object _timerLock = new object(); /// /// 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 /// 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); + } + } + + /// + /// Cancels the port forward timeout timer if a session is being explicitly stopped. + /// + private void CancelPortForwardTimeout() + { + lock (_timerLock) + { + _portForwardTimeoutTimer?.Dispose(); + _portForwardTimeoutTimer = null; + } + } } diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/RoutingFeedbackSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/RoutingFeedbackSessionRequestHandler.cs new file mode 100644 index 00000000..4b4981bb --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/RoutingFeedbackSessionRequestHandler.cs @@ -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; + +/// +/// 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. +/// +public class RoutingFeedbackSessionRequestHandler : WebApiBaseRequestHandler +{ + private static readonly RoutingFeedbackWebsocket _instance = new RoutingFeedbackWebsocket(); + private CTimer _portForwardTimeoutTimer; + private readonly object _timerLock = new object(); + + /// + /// Gets the singleton RoutingFeedbackWebsocket instance. + /// + public static RoutingFeedbackWebsocket Instance => _instance; + + /// + /// Constructor + /// + public RoutingFeedbackSessionRequestHandler() + : base(true) + { + } + + /// + /// Handles GET requests: starts the routing feedback WebSocket if not running and returns the URL. + /// + 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(); + } + } + + /// + /// Handles POST requests: stops the routing feedback WebSocket server. + /// + 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; + } + } +} diff --git a/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs b/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs new file mode 100644 index 00000000..a6766a96 --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs @@ -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; + +/// +/// 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. +/// +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 _debounceTimers = new Dictionary(); + + private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore + }; + + /// + public string Key => "RoutingFeedbackWebsocket"; + + /// + /// Gets the port number on which the server is currently running. + /// + public int Port => _httpsServer?.Port ?? 0; + + /// + /// Gets the WebSocket URL for the current server instance. + /// + 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}"; + } + } + + /// + /// Gets a value indicating whether the server is currently listening. + /// + public bool IsRunning => _httpsServer?.IsListening ?? false; + + /// + /// Gets a value indicating whether there are active WebSocket connections. + /// + 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; + } + } + + /// + /// Starts the WebSocket server on the specified port and subscribes to routing events. + /// + /// The port to listen on. + 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(_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; + } + } + + /// + /// Stops the WebSocket server and unsubscribes from routing events. + /// + 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); + } + } + + /// + /// Builds and returns the full current routing state snapshot to send to newly connected clients. + /// + internal string GetSnapshotMessage() + { + var midpointRoutes = new Dictionary>(); + var sinkRoutes = new Dictionary(); + + // Collect midpoint current routes + var midpointDevices = DeviceManager.AllDevices.OfType(); + 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(); + 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(); + foreach (var device in midpointDevices) + { + device.RouteChanged += HandleMidpointRouteChanged; + } + + var sinkDevices = DeviceManager.AllDevices.OfType(); + foreach (var device in sinkDevices) + { + device.InputChanged += HandleSinkInputChanged; + } + } + + private void UnsubscribeFromRoutingEvents() + { + var midpointDevices = DeviceManager.AllDevices.OfType(); + foreach (var device in midpointDevices) + { + device.RouteChanged -= HandleMidpointRouteChanged; + } + + var sinkDevices = DeviceManager.AllDevices.OfType(); + 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(); + + 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> MidpointRoutes { get; set; } + public Dictionary SinkRoutes { get; set; } + } + + private class MidpointRouteChangedDto + { + public string Type { get; set; } + public string DeviceKey { get; set; } + public List 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; } + } +} + +/// +/// WebSocket client behavior for routing feedback connections. +/// Sends a full state snapshot on connect. +/// +public class RoutingFeedbackClient : WebSocketBehavior +{ + private readonly RoutingFeedbackWebsocket _owner; + + /// + /// Initializes a new instance of the class. + /// + /// The owning instance. + public RoutingFeedbackClient(RoutingFeedbackWebsocket owner) + { + _owner = owner; + } + + /// + 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); + } + } + + /// + protected override void OnClose(CloseEventArgs e) + { + base.OnClose(e); + Debug.LogMessage(LogEventLevel.Debug, "Routing feedback client disconnected: {code} {reason}", _owner, e.Code, e.Reason); + } + + /// + protected override void OnError(WebSocketSharp.ErrorEventArgs e) + { + base.OnError(e); + Debug.LogError(e.Exception, "Routing feedback client error: {message}", _owner, e.Message); + } +}