mirror of
https://github.com/PepperDash/Essentials.git
synced 2026-07-02 10:38:16 +00:00
feat: implement Routing Feedback WebSocket service and associated request handlers
This commit is contained in:
parent
be545bbfa4
commit
b65f895e06
7 changed files with 812 additions and 5 deletions
|
|
@ -75,6 +75,20 @@ public class DebugWebsocketSink : ILogEventSink, IKeyed
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsRunning { get => _httpsServer?.IsListening ?? false; }
|
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/>
|
/// <inheritdoc/>
|
||||||
public string Key => "DebugWebsocketSink";
|
public string Key => "DebugWebsocketSink";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,10 @@ public interface IRunDirectRouteAction
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs a direct route from a source to a destination with an optional signal type for routing.
|
/// Runs a direct route from a source to a destination with an optional signal type for routing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sourceKey"></param>
|
/// <param name="source"></param>
|
||||||
/// <param name="destinationKey"></param>
|
/// <param name="destination"></param>
|
||||||
/// <param name="type"></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>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// This method updates the current source for the specified signal type and notifies any subscribers of the change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="signalType">The signal type to update.</param>
|
/// <param name="signalType">The signal type to update.</param>
|
||||||
|
///
|
||||||
/// <param name="sourceDevice">The source device to set as the current source.</param>
|
/// <param name="sourceDevice">The source device to set as the current source.</param>
|
||||||
void SetCurrentSource(eRoutingSignalType signalType, IRoutingSource sourceDevice);
|
void SetCurrentSource(eRoutingSignalType signalType, IRoutingSource sourceDevice);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,11 @@ public class EssentialsWebApi : EssentialsDevice
|
||||||
Name = "Get Routing Devices and TieLines",
|
Name = "Get Routing Devices and TieLines",
|
||||||
RouteHandler = new GetRoutingDevicesAndTieLinesHandler()
|
RouteHandler = new GetRoutingDevicesAndTieLinesHandler()
|
||||||
},
|
},
|
||||||
|
new HttpCwsRoute("routingFeedbackSession")
|
||||||
|
{
|
||||||
|
Name = "Routing Feedback WebSocket Session",
|
||||||
|
RouteHandler = new RoutingFeedbackSessionRequestHandler()
|
||||||
|
},
|
||||||
new HttpCwsRoute("initializationExceptions")
|
new HttpCwsRoute("initializationExceptions")
|
||||||
{
|
{
|
||||||
Name = "Get Initialization Exceptions",
|
Name = "Get Initialization Exceptions",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DebugSessionRequestHandler : WebApiBaseRequestHandler
|
public class DebugSessionRequestHandler : WebApiBaseRequestHandler
|
||||||
{
|
{
|
||||||
|
private CTimer _portForwardTimeoutTimer;
|
||||||
|
private readonly object _timerLock = new object();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Constructor
|
/// Constructor
|
||||||
|
|
@ -45,6 +47,7 @@ public class DebugSessionRequestHandler : WebApiBaseRequestHandler
|
||||||
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
|
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
|
||||||
|
|
||||||
var port = 0;
|
var port = 0;
|
||||||
|
string csIp = null;
|
||||||
|
|
||||||
if (!Debug.WebsocketSink.IsRunning)
|
if (!Debug.WebsocketSink.IsRunning)
|
||||||
{
|
{
|
||||||
|
|
@ -56,6 +59,40 @@ public class DebugSessionRequestHandler : WebApiBaseRequestHandler
|
||||||
Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose);
|
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)
|
if (!Debug.WebsocketSink.IsRunning)
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = 500;
|
context.Response.StatusCode = 500;
|
||||||
|
|
@ -67,12 +104,14 @@ public class DebugSessionRequestHandler : WebApiBaseRequestHandler
|
||||||
|
|
||||||
var url = Debug.WebsocketSink.Url;
|
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, "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
|
// Return the port number with the full url of the WS Server
|
||||||
var res = JsonConvert.SerializeObject(data);
|
var res = JsonConvert.SerializeObject(data);
|
||||||
|
|
@ -96,9 +135,42 @@ public class DebugSessionRequestHandler : WebApiBaseRequestHandler
|
||||||
/// <param name="context"></param>
|
/// <param name="context"></param>
|
||||||
protected override void HandlePost(HttpCwsContext context)
|
protected override void HandlePost(HttpCwsContext context)
|
||||||
{
|
{
|
||||||
|
CancelPortForwardTimeout();
|
||||||
|
|
||||||
|
var port = Debug.WebsocketSink.Port;
|
||||||
|
|
||||||
Task.Run(() => Debug.WebsocketSink.StopServer());
|
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.StatusCode = 200;
|
||||||
context.Response.StatusDescription = "OK";
|
context.Response.StatusDescription = "OK";
|
||||||
context.Response.End();
|
context.Response.End();
|
||||||
|
|
@ -106,4 +178,53 @@ public class DebugSessionRequestHandler : WebApiBaseRequestHandler
|
||||||
Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped");
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
433
src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs
Normal file
433
src/PepperDash.Essentials.Core/Web/RoutingFeedbackWebsocket.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue