diff --git a/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml b/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml index 291e9371..d5990e99 100644 --- a/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml +++ b/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml @@ -19,4 +19,5 @@ jobs: version: ${{ needs.getVersion.outputs.version }} tag: ${{ needs.getVersion.outputs.tag }} channel: ${{ needs.getVersion.outputs.channel }} - bypassPackageCheck: true \ No newline at end of file + bypassPackageCheck: true + devToolsVersion: ${{ vars.ESSENTIALSDEVTOOLSVERSION }} \ No newline at end of file diff --git a/docs/docs/usage/GenericComm.md b/docs/docs/usage/GenericComm.md index 243536e5..16f467e2 100644 --- a/docs/docs/usage/GenericComm.md +++ b/docs/docs/usage/GenericComm.md @@ -183,11 +183,12 @@ namespace PepperDash.Core Cresnet = 8, Cec = 9, Udp = 10, + UdpClient = 11, } } ``` -These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, and ```Udp```. +These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, ```UdpClient```, and ```Udp```. ##### ComParams @@ -287,7 +288,7 @@ This property maps to the number of the port on the device you have mapped the r ##### TcpSshParams -A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket. +A ```Ssh```, ```TcpIp```, ```UdpClient```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket. ```Json { @@ -304,7 +305,7 @@ A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties``` **```address```** -This is the IP address, hostname, or FQDN of the resource you wish to open a socket to. In the case of a UDP device, you can set either a single whitelist address with this data, or an appropriate broadcast address. +This is the IP address, hostname, or FQDN of the resource you wish to open a socket to. Use ```UdpClient``` for outbound UDP to a remote endpoint. Use ```Udp``` when you need Essentials to bind a local UDP listener. **```port```** diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 49e41a67..235fee48 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,11 +1,11 @@ - 2.19.4-local + 2.42.1-local $(Version) PepperDash Technology PepperDash Technology PepperDash Essentials - Copyright © 2025 + Copyright © 2026 https://github.com/PepperDash/Essentials git Crestron; 4series @@ -20,4 +20,7 @@ + + + diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs index df44ab51..546a2a67 100644 --- a/src/PepperDash.Core/Comm/GenericSshClient.cs +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -151,6 +151,8 @@ namespace PepperDash.Core // Thread-safety lock for state changes private readonly object _stateLock = new object(); + private volatile bool _isProgramStopping; + private bool disconnectLogged = false; /// @@ -207,11 +209,9 @@ namespace PepperDash.Core { if (programEventType == eProgramStatusEventType.Stopping) { - if (client != null) - { - this.LogDebug("Program stopping. Closing connection"); - Disconnect(); - } + _isProgramStopping = true; + this.LogDebug("Program stopping. Closing connection"); + Disconnect(); } } @@ -228,6 +228,12 @@ namespace PepperDash.Core return; } + if (_isProgramStopping) + { + this.LogDebug("Skipping connect because program is stopping"); + return; + } + ConnectEnabled = true; try @@ -287,13 +293,7 @@ namespace PepperDash.Core } catch (SshConnectionException e) { - var ie = e.InnerException; // The details are inside!! - - if (ie is SocketException) - { - this.LogError("CONNECTION failure: Cannot reach host"); - this.LogVerbose(ie, "Exception details: "); - } + var ie = e.InnerException; // The details are inside, when present - remote can close the connection with no inner exception at all if (ie is System.Net.Sockets.SocketException socketException) { @@ -301,20 +301,20 @@ namespace PepperDash.Core Hostname, Port); this.LogVerbose(socketException, "SocketException details: "); } - if (ie is SshAuthenticationException) + else if (ie is SshAuthenticationException) { this.LogError("Authentication failure for username {userName}", Username); this.LogVerbose(ie, "AuthenticationException details: "); } else { - this.LogError("Error on connect: {error}", ie.Message); - this.LogVerbose(ie, "Exception details: "); + this.LogError("Error on connect: {error}", ie?.Message ?? e.Message); + this.LogVerbose(ie ?? e, "Exception details: "); } disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -326,7 +326,7 @@ namespace PepperDash.Core disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -338,7 +338,7 @@ namespace PepperDash.Core this.LogVerbose(e, "Exception details: "); disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -473,7 +473,7 @@ namespace PepperDash.Core { connectLock.Release(); } - if (AutoReconnect && ConnectEnabled) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -516,7 +516,10 @@ namespace PepperDash.Core this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim()); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - StartReconnectTimer(); + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) + { + StartReconnectTimer(); + } } catch (Exception ex) { @@ -549,7 +552,10 @@ namespace PepperDash.Core this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes)); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - StartReconnectTimer(); + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) + { + StartReconnectTimer(); + } } catch (Exception ex) { diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs new file mode 100644 index 00000000..41792537 --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -0,0 +1,463 @@ +using System; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using ThreadingTimeout = System.Threading.Timeout; +using NetSocketException = System.Net.Sockets.SocketException; + +namespace PepperDash.Core +{ + /// + /// A class to handle basic UDP communications to a remote endpoint + /// + public class GenericUdpClient : Device, ISocketStatusWithStreamDebugging, IAutoReconnect + { + private const string SplusKey = "Uninitialized UdpClient"; + + private readonly object stateLock = new object(); + private readonly Timer reconnectTimer; + + private UdpClient client; + private CancellationTokenSource receiveCancellationTokenSource; + private bool connectEnabled; + private bool connectionRefusedLogged; + private SocketStatus clientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + + /// + /// Object to enable stream debugging + /// + public CommunicationStreamDebugging StreamDebugging { get; private set; } + + /// + /// Fires when data is received from the remote endpoint and returns it as a byte array + /// + public event EventHandler BytesReceived; + + /// + /// Fires when data is received from the remote endpoint and returns it as text + /// + public event EventHandler TextReceived; + + /// + /// Fires when the socket status changes + /// + public event EventHandler ConnectionChange; + + /// + /// Address of remote endpoint + /// + public string Hostname { get; set; } + + /// + /// Port on remote endpoint + /// + public int Port { get; set; } + + /// + /// Another S+ helper because large port numbers can be treated as signed ints + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// True when the local socket is created and associated with the configured remote endpoint + /// + public bool IsConnected + { + get { return ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// The current socket status of the client + /// + public SocketStatus ClientStatus + { + get + { + lock (stateLock) + { + return clientStatus; + } + } + private set + { + var shouldFireEvent = false; + + lock (stateLock) + { + if (clientStatus != value) + { + clientStatus = value; + shouldFireEvent = true; + } + } + + if (shouldFireEvent) + ConnectionChange?.Invoke(this, new GenericSocketStatusChageEventArgs(this)); + } + } + + /// + /// Ushort representation of client status + /// + public ushort UStatus + { + get { return (ushort)ClientStatus; } + } + + /// + /// Gets or sets the AutoReconnect + /// + public bool AutoReconnect { get; set; } + + /// + /// S+ helper for AutoReconnect + /// + public ushort UAutoReconnect + { + get { return (ushort)(AutoReconnect ? 1 : 0); } + set { AutoReconnect = value == 1; } + } + + /// + /// Milliseconds to wait before attempting to reconnect. Defaults to 5000 + /// + public int AutoReconnectIntervalMs { get; set; } + + /// + /// Constructor + /// + public GenericUdpClient(string key, string address, int port, int bufferSize) + : base(key) + { + StreamDebugging = new CommunicationStreamDebugging(key); + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + AutoReconnectIntervalMs = 5000; + Hostname = address; + Port = port; + BufferSize = bufferSize; + + reconnectTimer = new Timer(o => + { + if (connectEnabled) + Connect(); + }, null, ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + } + + /// + /// Constructor for S+ + /// + public GenericUdpClient() + : base(SplusKey) + { + StreamDebugging = new CommunicationStreamDebugging(SplusKey); + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + AutoReconnectIntervalMs = 5000; + BufferSize = 2000; + + reconnectTimer = new Timer(o => + { + if (connectEnabled) + Connect(); + }, null, ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + } + + /// + /// Initialize method + /// + public void Initialize(string key) + { + Key = key; + } + + private void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping) + { + Debug.Console(1, this, "Program stopping. Closing connection"); + Deactivate(); + } + } + + /// + /// Deactivate method + /// + public override bool Deactivate() + { + Disconnect(); + return true; + } + + /// + /// Connect method + /// + public void Connect() + { + if (string.IsNullOrEmpty(Hostname)) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': No address set", Key); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + return; + } + + if (Port < 1 || Port > 65535) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': Invalid port", Key); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + return; + } + + var hostname = Hostname; + var port = Port; + var bufferSize = BufferSize; + UdpClient newClient = null; + CancellationTokenSource newReceiveCancellationTokenSource = null; + CancellationToken startReceiveToken = default(CancellationToken); + var shouldStartReceive = false; + + lock (stateLock) + { + connectEnabled = true; + + if (client != null) + return; + } + + try + { + newReceiveCancellationTokenSource = new CancellationTokenSource(); + newClient = new UdpClient(); + newClient.Client.ReceiveBufferSize = bufferSize; + newClient.Client.SendBufferSize = bufferSize; + newClient.Connect(hostname, port); + + lock (stateLock) + { + if (!connectEnabled || client != null) + { + newClient.Close(); + newReceiveCancellationTokenSource.Cancel(); + newReceiveCancellationTokenSource.Dispose(); + return; + } + + receiveCancellationTokenSource = newReceiveCancellationTokenSource; + client = newClient; + ClientStatus = SocketStatus.SOCKET_STATUS_CONNECTED; + reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + startReceiveToken = receiveCancellationTokenSource.Token; + shouldStartReceive = true; + } + + if (shouldStartReceive) + StartReceive(startReceiveToken); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Error connecting UDP client {0}", this, Key); + + if (newClient != null) + newClient.Close(); + + if (newReceiveCancellationTokenSource != null) + { + newReceiveCancellationTokenSource.Cancel(); + newReceiveCancellationTokenSource.Dispose(); + } + + lock (stateLock) + { + if (connectEnabled && client == null) + { + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + StartReconnectTimer(); + } + } + } + } + + /// + /// Disconnect method + /// + public void Disconnect() + { + lock (stateLock) + { + connectEnabled = false; + reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + CleanupClient(); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + } + } + + /// + /// SendText method + /// + public void SendText(string text) + { + this.PrintSentText(text); + + var bytes = Encoding.GetEncoding(28591).GetBytes(text); + SendBytes(bytes); + } + + /// + /// SendBytes method + /// + public void SendBytes(byte[] bytes) + { + if (bytes == null) + return; + + try + { + this.PrintSentBytes(bytes); + + if (!IsConnected || client == null) + Connect(); + + var udpClient = client; + if (!IsConnected || udpClient == null) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': Cannot send bytes because the client is not connected", Key); + return; + } + + udpClient.Send(bytes, bytes.Length); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Error sending UDP bytes for {0}", this, Key); + HandleDisconnected(); + } + } + + private void StartReceive(CancellationToken token) + { + Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + try + { + var udpClient = client; + if (udpClient == null) + return; + + var result = await udpClient.ReceiveAsync().ConfigureAwait(false); + var bytes = result.Buffer; + if (bytes == null || bytes.Length == 0) + continue; + + connectionRefusedLogged = false; + + var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); + + this.PrintReceivedBytes(bytes); + this.PrintReceivedText(text); + + BytesReceived?.Invoke(this, new GenericCommMethodReceiveBytesArgs(bytes)); + TextReceived?.Invoke(this, new GenericCommMethodReceiveTextArgs(text)); + } + catch (ObjectDisposedException) + { + return; + } + catch (InvalidOperationException) + { + return; + } + catch (NetSocketException ex) + { + if (ex.SocketErrorCode == SocketError.ConnectionRefused) + { + if (!connectionRefusedLogged) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, + "GenericUdpClient '{0}': Remote endpoint refused UDP traffic or is no longer listening", + Key); + connectionRefusedLogged = true; + } + + HandleDisconnected(); + return; + } + + Debug.LogMessage(ex, "UDP receive error for {0}", this, Key); + + if (AutoReconnect) + { + HandleDisconnected(); + return; + } + + continue; + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Unexpected UDP receive error for {0}", this, Key); + + if (AutoReconnect) + { + HandleDisconnected(); + return; + } + + continue; + } + } + }, token); + } + + private void HandleDisconnected() + { + lock (stateLock) + { + CleanupClient(); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + StartReconnectTimer(); + } + } + + private void StartReconnectTimer() + { + if (AutoReconnect && connectEnabled) + reconnectTimer.Change(AutoReconnectIntervalMs, ThreadingTimeout.Infinite); + } + + private void CleanupClient() + { + if (receiveCancellationTokenSource != null) + { + receiveCancellationTokenSource.Cancel(); + receiveCancellationTokenSource.Dispose(); + receiveCancellationTokenSource = null; + } + + if (client != null) + { + client.Close(); + client = null; + } + } + } +} diff --git a/src/PepperDash.Core/Comm/eControlMethods.cs b/src/PepperDash.Core/Comm/eControlMethods.cs index b807fdc5..a1a30734 100644 --- a/src/PepperDash.Core/Comm/eControlMethods.cs +++ b/src/PepperDash.Core/Comm/eControlMethods.cs @@ -18,70 +18,75 @@ namespace PepperDash.Core /// /// RS232/422/485 /// - Com, + Com = 1, /// /// Crestron IpId (most Crestron ethernet devices) /// - IpId, + IpId = 2, /// /// Crestron IpIdTcp (HD-MD series, etc.) /// - IpidTcp, + IpidTcp = 3, /// /// Crestron IR control /// - IR, + IR = 4, /// /// SSH client /// - Ssh, + Ssh = 5, /// /// TCP/IP client /// - Tcpip, + Tcpip = 6, /// /// Telnet /// - Telnet, + Telnet = 7, /// /// Crestnet device /// - Cresnet, + Cresnet = 8, /// /// CEC Control, via a DM HDMI port /// - Cec, + Cec = 9, /// /// UDP Server /// - Udp, + Udp = 10, + /// /// HTTP client /// - Http, + Http = 11, /// /// HTTPS client /// - Https, + Https = 12, /// /// Websocket client /// - Ws, + Ws = 13, /// /// Secure Websocket client /// - Wss, + Wss = 14, /// /// Secure TCP/IP /// - SecureTcpIp, + SecureTcpIp = 15, /// /// Used when comms needs to be handled in SIMPL and bridged opposite the normal direction /// - ComBridge, + ComBridge = 16, /// /// InfinetEX control /// - InfinetEx + InfinetEx = 17, + /// + /// UDP client + /// + UdpClient = 18, } } \ No newline at end of file diff --git a/src/PepperDash.Core/Device.cs b/src/PepperDash.Core/Device.cs index 7124550e..58b5fe3e 100644 --- a/src/PepperDash.Core/Device.cs +++ b/src/PepperDash.Core/Device.cs @@ -5,9 +5,9 @@ using Serilog.Events; namespace PepperDash.Core { //********************************************************************************************************* - /// - /// Represents a Device - /// + /// + /// Represents a Device + /// public class Device : IKeyName { @@ -15,25 +15,15 @@ namespace PepperDash.Core /// Unique Key /// public string Key { get; protected set; } - /// - /// Gets or sets the Name - /// + /// + /// Gets or sets the Name + /// public string Name { get; protected set; } /// /// /// public bool Enabled { get; protected set; } - /// - /// A place to store reference to the original config object, if any. These values should - /// NOT be used as properties on the device as they are all publicly-settable values. - /// - //public DeviceConfig Config { get; private set; } - /// - /// Helper method to check if Config exists - /// - //public bool HasConfig { get { return Config != null; } } - List _PreActivationActions; List _PostActivationActions; @@ -86,9 +76,9 @@ namespace PepperDash.Core /// Adds a post activation action /// /// - /// - /// AddPostActivationAction method - /// + /// + /// AddPostActivationAction method + /// public void AddPostActivationAction(Action act) { if (_PostActivationActions == null) @@ -96,9 +86,9 @@ namespace PepperDash.Core _PostActivationActions.Add(act); } - /// - /// PreActivate method - /// + /// + /// PreActivate method + /// public void PreActivate() { if (_PreActivationActions != null) @@ -115,9 +105,9 @@ namespace PepperDash.Core }); } - /// - /// Activate method - /// + /// + /// Activate method + /// public bool Activate() { //if (_PreActivationActions != null) @@ -128,9 +118,9 @@ namespace PepperDash.Core return result; } - /// - /// PostActivate method - /// + /// + /// PostActivate method + /// public void PostActivate() { if (_PostActivationActions != null) @@ -153,9 +143,9 @@ namespace PepperDash.Core /// do not need to call base.CustomActivate() /// /// true if device activated successfully. - /// - /// CustomActivate method - /// + /// + /// CustomActivate method + /// public virtual bool CustomActivate() { return true; } /// @@ -182,15 +172,15 @@ namespace PepperDash.Core if (o is bool && !(bool)o) a(); } - /// - /// Returns a string representation of the object, including its key and name. - /// - /// The returned string is formatted as "{Key} - {Name}". If the Name property is - /// null or empty, "---" is used in place of the name. - /// A string that represents the object, containing the key and name in the format "{Key} - {Name}". - /// - /// ToString method - /// + /// + /// Returns a string representation of the object, including its key and name. + /// + /// The returned string is formatted as "{Key} - {Name}". If the Name property is + /// null or empty, "---" is used in place of the name. + /// A string that represents the object, containing the key and name in the format "{Key} - {Name}". + /// + /// ToString method + /// public override string ToString() { return string.Format("{0} - {1}", Key, string.IsNullOrEmpty(Name) ? "---" : Name); diff --git a/src/PepperDash.Core/Logging/Debug.cs b/src/PepperDash.Core/Logging/Debug.cs index 877d8997..97ac5890 100644 --- a/src/PepperDash.Core/Logging/Debug.cs +++ b/src/PepperDash.Core/Logging/Debug.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.Collections.Generic; -using System.Net; using System.Reflection; using System.Text.RegularExpressions; using Crestron.SimplSharp; diff --git a/src/PepperDash.Core/Logging/DebugContext.cs b/src/PepperDash.Core/Logging/DebugContext.cs index e90ec86a..66e63634 100644 --- a/src/PepperDash.Core/Logging/DebugContext.cs +++ b/src/PepperDash.Core/Logging/DebugContext.cs @@ -11,6 +11,7 @@ namespace PepperDash.Core /// /// Represents a debugging context /// + [Obsolete("DebugContext is no longer supported and will be removed in a future release.")] public class DebugContext { /// diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 9c3df14e..cfbf5785 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Serilog; using Serilog.Core; using Serilog.Events; @@ -12,11 +8,18 @@ using Crestron.SimplSharp; using WebSocketSharp; using System.Security.Authentication; using WebSocketSharp.Net; -using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2; +using System.Security.Cryptography.X509Certificates; using System.IO; +using Org.BouncyCastle.Asn1; using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; using Serilog.Formatting; -using Newtonsoft.Json.Linq; using Serilog.Formatting.Json; namespace PepperDash.Core @@ -27,26 +30,40 @@ namespace PepperDash.Core public class DebugWebsocketSink : ILogEventSink { private HttpServer _httpsServer; - + private string _path = "/debug/join/"; private const string _certificateName = "selfCres"; private const string _certificatePassword = "cres12345"; - public int Port - { get - { - - if(_httpsServer == null) return 0; + private static string CertPath => + $"{Path.DirectorySeparatorChar}user{Path.DirectorySeparatorChar}{_certificateName}.pfx"; + + + public int Port + { + get + { + + if (_httpsServer == null) return 0; return _httpsServer.Port; - } + } } public string Url { get { - if (_httpsServer == null) return ""; - return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{_httpsServer.WebSocketServices[_path].Path}"; + if (_httpsServer == null || !_httpsServer.IsListening) return ""; + var service = _httpsServer.WebSocketServices[_path]; + if (service == null) return ""; + + // Use CSLAN IP if available, otherwise fallback to primary IP. This ensures we provide a reachable URL in dual-stack environments. + var cslanIp = CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1); + if (!string.IsNullOrEmpty(cslanIp) && cslanIp != "Invalid Value") + return $"wss://{cslanIp}:{_httpsServer.Port}{service.Path}"; + else + return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{service.Path}"; } } @@ -54,60 +71,138 @@ namespace PepperDash.Core /// Gets or sets the IsRunning /// 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; + } + } + private readonly ITextFormatter _textFormatter; + /// + /// Initializes a new instance of the class with the specified text formatter. + /// + /// This constructor initializes the WebSocket sink and ensures that a certificate is + /// available for secure communication. If the required certificate does not exist, it will be created + /// automatically. Additionally, the sink is configured to stop the server when the program is + /// stopping. + /// The text formatter used to format log messages. If null, a default JSON formatter is used. public DebugWebsocketSink(ITextFormatter formatProvider) { _textFormatter = formatProvider ?? new JsonFormatter(); - if (!File.Exists($"\\user\\{_certificateName}.pfx")) - CreateCert(null); + if (!File.Exists(CertPath)) + CreateCert(); - CrestronEnvironment.ProgramStatusEventHandler += type => - { - if (type == eProgramStatusEventType.Stopping) - { - StopServer(); - } - }; - } - - private void CreateCert(string[] args) - { try { - //Debug.Console(0,"CreateCert Creating Utility"); - CrestronConsole.PrintLine("CreateCert Creating Utility"); - //var utility = new CertificateUtility(); - var utility = new BouncyCertificate(); - //Debug.Console(0, "CreateCert Calling CreateCert"); - CrestronConsole.PrintLine("CreateCert Calling CreateCert"); - //utility.CreateCert(); + CrestronEnvironment.ProgramStatusEventHandler += type => + { + if (type == eProgramStatusEventType.Stopping) + StopServer(); + }; + } + catch + { + // CrestronEnvironment is not available in test / dev environments — safe to skip. + } + } + + private static void CreateCert() + { + // NOTE: This method is called from the constructor, which is itself called during Debug's static + // constructor before _logger is assigned. Do NOT call any Debug.Log* methods here — use + // CrestronConsole.PrintLine only, to avoid a NullReferenceException that would poison the Debug type. + try + { var ipAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); var hostName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0); var domainName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, 0); - //Debug.Console(0, "DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress); - CrestronConsole.PrintLine(string.Format("DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress)); + CrestronConsole.PrintLine(string.Format("CreateCert: DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress)); - var certificate = utility.CreateSelfSignedCertificate(string.Format("CN={0}.{1}", hostName, domainName), new[] { string.Format("{0}.{1}", hostName, domainName), ipAddress }, new[] { KeyPurposeID.id_kp_serverAuth, KeyPurposeID.id_kp_clientAuth }); - //Crestron fails to let us do this...perhaps it should be done through their Dll's but haven't tested - //Debug.Print($"CreateCert Storing Certificate To My.LocalMachine"); - //utility.AddCertToStore(certificate, StoreName.My, StoreLocation.LocalMachine); - //Debug.Console(0, "CreateCert Saving Cert to \\user\\"); - CrestronConsole.PrintLine("CreateCert Saving Cert to \\user\\"); - utility.CertificatePassword = _certificatePassword; - utility.WriteCertificate(certificate, @"\user\", _certificateName); - //Debug.Console(0, "CreateCert Ending CreateCert"); - CrestronConsole.PrintLine("CreateCert Ending CreateCert"); + var subjectName = string.Format("CN={0}.{1}", hostName, domainName); + var fqdn = string.Format("{0}.{1}", hostName, domainName); + + var random = new SecureRandom(); + + // Generate RSA 2048 key pair + var keyPairGenerator = new RsaKeyPairGenerator(); + keyPairGenerator.Init(new KeyGenerationParameters(random, 2048)); + var keyPair = keyPairGenerator.GenerateKeyPair(); + + // Build certificate + var certGenerator = new X509V3CertificateGenerator(); + certGenerator.SetSerialNumber(BigInteger.ValueOf(Math.Abs(DateTime.UtcNow.Ticks))); + certGenerator.SetIssuerDN(new X509Name(subjectName)); + certGenerator.SetSubjectDN(new X509Name(subjectName)); + certGenerator.SetNotBefore(DateTime.UtcNow); + certGenerator.SetNotAfter(DateTime.UtcNow.AddYears(2)); + certGenerator.SetPublicKey(keyPair.Public); + + // Extended Key Usage: server + client auth + certGenerator.AddExtension(X509Extensions.ExtendedKeyUsage, false, + new ExtendedKeyUsage(new[] { KeyPurposeID.id_kp_serverAuth, KeyPurposeID.id_kp_clientAuth })); + + // Subject Alternative Names: DNS + IP + System.Net.IPAddress parsedIp; + if (System.Net.IPAddress.TryParse(ipAddress, out parsedIp)) + { + certGenerator.AddExtension(X509Extensions.SubjectAlternativeName, false, + new GeneralNames(new GeneralName[] { + new GeneralName(GeneralName.DnsName, fqdn), + new GeneralName(GeneralName.IPAddress, ipAddress) + })); + } + else + { + certGenerator.AddExtension(X509Extensions.SubjectAlternativeName, false, + new GeneralNames(new GeneralName(GeneralName.DnsName, fqdn))); + } + + // Sign with SHA256withRSA + var signatureFactory = new Asn1SignatureFactory("SHA256WITHRSA", keyPair.Private, random); + var certificate = certGenerator.Generate(signatureFactory); + + // Export as PKCS12/PFX + var pkcs12Store = new Pkcs12StoreBuilder().Build(); + var certEntry = new X509CertificateEntry(certificate); + pkcs12Store.SetCertificateEntry(_certificateName, certEntry); + pkcs12Store.SetKeyEntry(_certificateName, new AsymmetricKeyEntry(keyPair.Private), new[] { certEntry }); + + var separator = Path.DirectorySeparatorChar; + var outputPath = string.Format("{0}user{1}{2}.pfx", separator, separator, _certificateName); + + using (var ms = new MemoryStream()) + { + var passwordChars = _certificatePassword.ToCharArray(); + try + { + pkcs12Store.Save(ms, passwordChars, random); + } + finally + { + Array.Clear(passwordChars, 0, passwordChars.Length); + } + File.WriteAllBytes(outputPath, ms.ToArray()); + } + + CrestronConsole.PrintLine(string.Format("CreateCert: Certificate written to {0}", outputPath)); } catch (Exception ex) { - //Debug.Console(0, "WSS CreateCert Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace); - CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed\r\n{0}\r\n{1}", ex.Message, ex.StackTrace)); + CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed: {0}\r\n{1}", ex.Message, ex.StackTrace)); } } @@ -126,14 +221,77 @@ namespace PepperDash.Core } /// - /// StartServerAndSetPort method + /// Starts the WebSocket server on the specified port and configures it with the appropriate certificate. /// + /// This method initializes the WebSocket server and binds it to the specified port. It + /// also applies the server's certificate for secure communication. Ensure that the port is not already in use + /// and that the certificate file is accessible. + /// The port number on which the WebSocket server will listen. Must be a valid, non-negative port number. public void StartServerAndSetPort(int port) { - Debug.Console(0, "Starting Websocket Server on port: {0}", port); + Debug.LogInformation("Starting Websocket Server on port: {0}", port); + + - Start(port, $"\\user\\{_certificateName}.pfx", _certificatePassword); + Start(port, CertPath, _certificatePassword); + } + + private static X509Certificate2 LoadOrRecreateCert(string certPath, string certPassword) + { + if (!File.Exists(certPath)) + CreateCert(); + + try + { + return LoadCertFromBouncyCastle(certPath, certPassword); + } + catch (Exception ex) + { + // Cert is corrupt or was written by an incompatible tool — delete and regenerate once. + CrestronConsole.PrintLine(string.Format("SSL cert load failed ({0}); regenerating...", ex.Message)); + try { File.Delete(certPath); } catch { } + CreateCert(); + return LoadCertFromBouncyCastle(certPath, certPassword); + } + } + + /// + /// Loads a PKCS#12 file written by BouncyCastle and returns an with + /// private key attached. + /// The PFX is parsed and re-encoded by BouncyCastle (ensuring format compatibility), then passed as + /// raw bytes to so neither RSACryptoServiceProvider nor the + /// EphemeralKeySet flag (unsupported on the Crestron/Mono runtime) is needed. + /// + private static X509Certificate2 LoadCertFromBouncyCastle(string certPath, string certPassword) + { + var passwordChars = certPassword.ToCharArray(); + try + { + using (var stream = File.OpenRead(certPath)) + { + var store = new Pkcs12StoreBuilder().Build(); + store.Load(stream, passwordChars); + + // Re-encode through BouncyCastle to guarantee PKCS#12 format compatibility, + // then hand raw bytes to X509Certificate2 — no RSACryptoServiceProvider needed. + using (var ms = new MemoryStream()) + { + store.Save(ms, passwordChars, new SecureRandom()); + var cert = new X509Certificate2(ms.ToArray(), certPassword); + + if (!cert.HasPrivateKey) + throw new InvalidOperationException( + string.Format("Certificate loaded from '{0}' does not contain a private key and cannot be used as a server certificate.", certPath)); + + return cert; + } + } + } + finally + { + Array.Clear(passwordChars, 0, passwordChars.Length); + } } private void Start(int port, string certPath = "", string certPassword = "") @@ -142,66 +300,37 @@ namespace PepperDash.Core { _httpsServer = new HttpServer(port, true); - if (!string.IsNullOrWhiteSpace(certPath)) { - Debug.Console(0, "Assigning SSL Configuration"); - _httpsServer.SslConfiguration = new ServerSslConfiguration(new X509Certificate2(certPath, certPassword)) - { - ClientCertificateRequired = false, - CheckCertificateRevocation = false, - EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 | SslProtocols.Tls, - //this is just to test, you might want to actually validate - ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => - { - Debug.Console(0, "HTTPS ClientCerticateValidation Callback triggered"); - return true; - } - }; - } - Debug.Console(0, "Adding Debug Client Service"); - _httpsServer.AddWebSocketService(_path); - Debug.Console(0, "Assigning Log Info"); - _httpsServer.Log.Level = LogLevel.Trace; - _httpsServer.Log.Output = (d, s) => - { - uint level; + Debug.LogInformation("Assigning SSL Configuration"); - switch(d.Level) - { - case WebSocketSharp.LogLevel.Fatal: - level = 3; - break; - case WebSocketSharp.LogLevel.Error: - level = 2; - break; - case WebSocketSharp.LogLevel.Warn: - level = 1; - break; - case WebSocketSharp.LogLevel.Info: - level = 0; - break; - case WebSocketSharp.LogLevel.Debug: - level = 4; - break; - case WebSocketSharp.LogLevel.Trace: - level = 5; - break; - default: - level = 4; - break; - } - - Debug.Console(level, "{1} {0}\rCaller:{2}\rMessage:{3}\rs:{4}", d.Level.ToString(), d.Date.ToString(), d.Caller.ToString(), d.Message, s); - }; - Debug.Console(0, "Starting"); + _httpsServer.SslConfiguration.ServerCertificate = LoadOrRecreateCert(certPath, certPassword); + _httpsServer.SslConfiguration.ClientCertificateRequired = false; + _httpsServer.SslConfiguration.CheckCertificateRevocation = false; + _httpsServer.SslConfiguration.EnabledSslProtocols = SslProtocols.Tls12; + //this is just to test, you might want to actually validate + _httpsServer.SslConfiguration.ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => + { + Debug.LogInformation("HTTPS ClientCerticateValidation Callback triggered"); + return true; + }; + } + Debug.LogInformation("Adding Debug Client Service"); + _httpsServer.AddWebSocketService(_path); + Debug.LogInformation("Assigning Log Info"); + _httpsServer.Log.Level = LogLevel.Trace; + _httpsServer.Log.Output = WriteWebSocketInternalLog; + Debug.LogInformation("Starting"); _httpsServer.Start(); - Debug.Console(0, "Ready"); + Debug.LogInformation("Ready"); } catch (Exception ex) { - Debug.Console(0, "WebSocket Failed to start {0}", ex.Message); + Debug.LogError(ex, "WebSocket Failed to start {0}", ex.Message); + Debug.LogVerbose("Stack Trace:\r{0}", ex.StackTrace); + // Null out the server so callers can detect failure via IsRunning / Url null guards. + _httpsServer = null; } } @@ -210,10 +339,68 @@ namespace PepperDash.Core /// public void StopServer() { - Debug.Console(0, "Stopping Websocket Server"); - _httpsServer?.Stop(); + Debug.LogInformation("Stopping Websocket Server"); - _httpsServer = null; + try + { + if (_httpsServer == null || !_httpsServer.IsListening) + { + return; + } + + // Prevent close-sequence internal websocket logs from re-entering the logging pipeline. + _httpsServer.Log.Output = (d, s) => { }; + + var serviceHost = _httpsServer.WebSocketServices[_path]; + + if (serviceHost == null) + { + _httpsServer.Stop(); + _httpsServer = null; + return; + } + + serviceHost.Sessions.Broadcast("Server is stopping"); + + foreach (var session in serviceHost.Sessions.Sessions) + { + if (session?.Context?.WebSocket != null && session.Context.WebSocket.IsAlive) + { + session.Context.WebSocket.Close(1001, "Server is stopping"); + } + } + + _httpsServer.Stop(); + + _httpsServer = null; + + } + catch (Exception ex) + { + Debug.LogError(ex, "WebSocket Failed to stop gracefully {0}", ex.Message); + Debug.LogVerbose("Stack Trace\r\n{0}", ex.StackTrace); + } + } + + private static void WriteWebSocketInternalLog(LogData data, string supplemental) + { + try + { + if (data == null) + { + return; + } + + var message = string.IsNullOrWhiteSpace(data.Message) ? "" : data.Message; + var details = string.IsNullOrWhiteSpace(supplemental) ? string.Empty : string.Format(" | details: {0}", supplemental); + + // Use direct console output to avoid recursive log sink calls. + CrestronConsole.PrintLine(string.Format("WS[{0}] {1} | message: {2}{3}", data.Level, data.Date, message, details)); + } + catch + { + // Never throw from websocket log callback. + } } } diff --git a/src/PepperDash.Core/PepperDash.Core.csproj b/src/PepperDash.Core/PepperDash.Core.csproj index daa5c6da..747aefb6 100644 --- a/src/PepperDash.Core/PepperDash.Core.csproj +++ b/src/PepperDash.Core/PepperDash.Core.csproj @@ -43,7 +43,7 @@ - + diff --git a/src/PepperDash.Core/Web/WebApiServer.cs b/src/PepperDash.Core/Web/WebApiServer.cs index 11ec4e2f..f07aab42 100644 --- a/src/PepperDash.Core/Web/WebApiServer.cs +++ b/src/PepperDash.Core/Web/WebApiServer.cs @@ -5,6 +5,7 @@ using Crestron.SimplSharp; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using PepperDash.Core.Logging; using PepperDash.Core.Web.RequestHandlers; namespace PepperDash.Core.Web @@ -25,29 +26,26 @@ namespace PepperDash.Core.Web private readonly CCriticalSection _serverLock = new CCriticalSection(); private HttpCwsServer _server; - /// - /// Gets or sets the Key - /// + /// + /// Gets or sets the Key + /// public string Key { get; private set; } - /// - /// Gets or sets the Name - /// + /// + /// Gets or sets the Name + /// public string Name { get; private set; } - /// - /// Gets or sets the BasePath - /// + /// + /// Gets or sets the BasePath + /// public string BasePath { get; private set; } - /// - /// Gets or sets the IsRegistered - /// + /// + /// Gets or sets the IsRegistered + /// public bool IsRegistered { get; private set; } - /// - /// Http request handler - /// //public IHttpCwsHandler HttpRequestHandler //{ // get { return _server.HttpRequestHandler; } @@ -58,9 +56,6 @@ namespace PepperDash.Core.Web // } //} - /// - /// Received request event handler - /// //public event EventHandler ReceivedRequestEvent //{ // add { _server.ReceivedRequestEvent += new HttpCwsRequestEventHandler(value); } @@ -91,7 +86,7 @@ namespace PepperDash.Core.Web /// /// /// - public WebApiServer(string key, string name, string basePath) + public WebApiServer(string key, string name, string basePath) { Key = key; Name = string.IsNullOrEmpty(name) ? DefaultName : name; @@ -99,6 +94,8 @@ namespace PepperDash.Core.Web if (_server == null) _server = new HttpCwsServer(BasePath); + _server.AuthenticateAllRoutes = false; + _server.setProcessName(Key); _server.HttpRequestHandler = new DefaultRequestHandler(); @@ -114,7 +111,7 @@ namespace PepperDash.Core.Web { if (programEventType != eProgramStatusEventType.Stopping) return; - Debug.Console(DebugInfo, this, "Program stopping. stopping server"); + this.LogInformation("Program stopping. stopping server"); Stop(); } @@ -128,18 +125,18 @@ namespace PepperDash.Core.Web // Re-enable the server if the link comes back up and the status should be connected if (ethernetEventArgs.EthernetEventType == eEthernetEventType.LinkUp && IsRegistered) { - Debug.Console(DebugInfo, this, "Ethernet link up. Server is alreedy registered."); + this.LogInformation("Ethernet link up. Server is alreedy registered."); return; } - Debug.Console(DebugInfo, this, "Ethernet link up. Starting server"); + this.LogInformation("Ethernet link up. Starting server"); Start(); } - /// - /// Initialize method - /// + /// + /// Initialize method + /// public void Initialize(string key, string basePath) { Key = key; @@ -153,7 +150,7 @@ namespace PepperDash.Core.Web { if (route == null) { - Debug.Console(DebugInfo, this, "Failed to add route, route parameter is null"); + this.LogWarning("Failed to add route, route parameter is null"); return; } @@ -165,23 +162,36 @@ namespace PepperDash.Core.Web /// Removes a route from CWS /// /// - /// - /// RemoveRoute method - /// public void RemoveRoute(HttpCwsRoute route) { if (route == null) { - Debug.Console(DebugInfo, this, "Failed to remote route, orute parameter is null"); + this.LogWarning("Failed to remove route, route parameter is null"); return; } _server.Routes.Remove(route); } - /// - /// GetRouteCollection method - /// + /// + /// Sets the fallback request handler that is invoked when no registered route + /// matches an incoming request. Must be called before . + /// + /// The handler to use as the server-level fallback. + public void SetFallbackHandler(IHttpCwsHandler handler) + { + if (handler == null) + { + this.LogWarning("SetFallbackHandler: handler parameter is null, ignoring"); + return; + } + + _server.HttpRequestHandler = handler; + } + + /// + /// GetRouteCollection method + /// public HttpCwsRouteCollection GetRouteCollection() { return _server.Routes; @@ -198,26 +208,24 @@ namespace PepperDash.Core.Web if (_server == null) { - Debug.Console(DebugInfo, this, "Server is null, unable to start"); + this.LogWarning("Server is null, unable to start"); return; } if (IsRegistered) { - Debug.Console(DebugInfo, this, "Server has already been started"); + this.LogWarning("Server has already been started"); return; } IsRegistered = _server.Register(); - Debug.Console(DebugInfo, this, "Starting server, registration {0}", IsRegistered ? "was successful" : "failed"); + this.LogInformation("Starting server, registration {registrationResult}", IsRegistered ? "was successful" : "failed"); } catch (Exception ex) { - Debug.Console(DebugInfo, this, "Start Exception Message: {0}", ex.Message); - Debug.Console(DebugVerbose, this, "Start Exception StackTrace: {0}", ex.StackTrace); - if (ex.InnerException != null) - Debug.Console(DebugVerbose, this, "Start Exception InnerException: {0}", ex.InnerException); + this.LogError("Start Exception Message: {message}", ex.Message); + this.LogDebug(ex, "Start Exception StackTrace"); } finally { @@ -225,9 +233,9 @@ namespace PepperDash.Core.Web } } - /// - /// Stop method - /// + /// + /// Stop method + /// public void Stop() { try @@ -236,23 +244,21 @@ namespace PepperDash.Core.Web if (_server == null) { - Debug.Console(DebugInfo, this, "Server is null or has already been stopped"); + this.LogWarning("Server is null or has already been stopped"); return; } IsRegistered = _server.Unregister() == false; - Debug.Console(DebugInfo, this, "Stopping server, unregistration {0}", IsRegistered ? "failed" : "was successful"); + this.LogInformation("Stopping server, unregistration {unregistrationResult}", IsRegistered ? "failed" : "was successful"); _server.Dispose(); _server = null; } catch (Exception ex) { - Debug.Console(DebugInfo, this, "Server Stop Exception Message: {0}", ex.Message); - Debug.Console(DebugVerbose, this, "Server Stop Exception StackTrace: {0}", ex.StackTrace); - if (ex.InnerException != null) - Debug.Console(DebugVerbose, this, "Server Stop Exception InnerException: {0}", ex.InnerException); + this.LogError("Server Stop Exception Message: {message}", ex.Message); + this.LogDebug(ex, "Server Stop Exception StackTrace"); } finally { @@ -273,14 +279,12 @@ namespace PepperDash.Core.Web try { var j = JsonConvert.SerializeObject(args.Context, Formatting.Indented); - Debug.Console(DebugVerbose, this, "RecieveRequestEventHandler Context:\x0d\x0a{0}", j); + this.LogVerbose("RecieveRequestEventHandler Context:\x0d\x0a{0}", j); } catch (Exception ex) { - Debug.Console(DebugInfo, this, "ReceivedRequestEventHandler Exception Message: {0}", ex.Message); - Debug.Console(DebugVerbose, this, "ReceivedRequestEventHandler Exception StackTrace: {0}", ex.StackTrace); - if (ex.InnerException != null) - Debug.Console(DebugVerbose, this, "ReceivedRequestEventHandler Exception InnerException: {0}", ex.InnerException); + this.LogError("ReceivedRequestEventHandler Exception Message: {message}", ex.Message); + this.LogDebug(ex, "ReceivedRequestEventHandler Exception StackTrace: {stackTrace}", ex.StackTrace); } } } diff --git a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs index f8c815fd..131430a2 100644 --- a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs +++ b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs @@ -1,5 +1,5 @@ using System; -using Crestron.SimplSharp; // For Basic SIMPL# Classes +using Crestron.SimplSharp; // For Basic SIMPL# Classes using Crestron.SimplSharp.CrestronIO; using Crestron.SimplSharp.Net.Http; using Crestron.SimplSharp.Net.Https; @@ -10,24 +10,25 @@ using PepperDash.Core.JsonToSimpl; namespace PepperDash.Core.WebApi.Presets { - /// - /// Passcode client for the WebApi - /// + /// + /// Passcode client for the WebApi + /// + [Obsolete("WebApiPasscodeClient is no longer supported and will be removed in a future release.")] public class WebApiPasscodeClient : IKeyed { - /// - /// Notifies when user received - /// + /// + /// Notifies when user received + /// public event EventHandler UserReceived; - /// - /// Notifies when Preset received - /// + /// + /// Notifies when Preset received + /// public event EventHandler PresetReceived; - /// - /// Gets or sets the Key - /// + /// + /// Gets or sets the Key + /// public string Key { get; private set; } //string JsonMasterKey; @@ -54,13 +55,13 @@ namespace PepperDash.Core.WebApi.Presets { } - /// - /// Initializes the instance - /// - /// - /// - /// - /// + /// + /// Initializes the instance + /// + /// + /// + /// + /// public void Initialize(string key, string jsonMasterKey, string urlBase, string defaultPresetJsonFilePath) { Key = key; @@ -73,44 +74,44 @@ namespace PepperDash.Core.WebApi.Presets J2SMaster.Initialize(jsonMasterKey); } - /// - /// Gets the user for a passcode - /// - /// - /// - /// GetUserForPasscode method - /// + /// + /// Gets the user for a passcode + /// + /// + /// + /// GetUserForPasscode method + /// public void GetUserForPasscode(string passcode) { - // Bullshit duplicate code here... These two cases should be the same - // except for https/http and the certificate ignores - if (!UrlBase.StartsWith("https")) - return; - var req = new HttpsClientRequest(); - req.Url = new UrlParser(UrlBase + "/api/users/dopin"); - req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; - req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); - req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); - var jo = new JObject(); - jo.Add("pin", passcode); - req.ContentString = jo.ToString(); + // Bullshit duplicate code here... These two cases should be the same + // except for https/http and the certificate ignores + if (!UrlBase.StartsWith("https")) + return; + var req = new HttpsClientRequest(); + req.Url = new UrlParser(UrlBase + "/api/users/dopin"); + req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; + req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); + req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); + var jo = new JObject(); + jo.Add("pin", passcode); + req.ContentString = jo.ToString(); - var client = new HttpsClient(); - client.HostVerification = false; - client.PeerVerification = false; - var resp = client.Dispatch(req); - var handler = UserReceived; - if (resp.Code == 200) - { - //CrestronConsole.PrintLine("Received: {0}", resp.ContentString); - var user = JsonConvert.DeserializeObject(resp.ContentString); - CurrentUser = user; - if (handler != null) - UserReceived(this, new UserReceivedEventArgs(user, true)); - } - else - if (handler != null) - UserReceived(this, new UserReceivedEventArgs(null, false)); + var client = new HttpsClient(); + client.HostVerification = false; + client.PeerVerification = false; + var resp = client.Dispatch(req); + var handler = UserReceived; + if (resp.Code == 200) + { + //CrestronConsole.PrintLine("Received: {0}", resp.ContentString); + var user = JsonConvert.DeserializeObject(resp.ContentString); + CurrentUser = user; + if (handler != null) + handler(this, new UserReceivedEventArgs(user, true)); + } + else + if (handler != null) + handler(this, new UserReceivedEventArgs(null, false)); } /// @@ -118,9 +119,9 @@ namespace PepperDash.Core.WebApi.Presets /// /// /// - /// - /// GetPresetForThisUser method - /// + /// + /// GetPresetForThisUser method + /// public void GetPresetForThisUser(int roomTypeId, int presetNumber) { if (CurrentUser == null) @@ -136,57 +137,57 @@ namespace PepperDash.Core.WebApi.Presets PresetNumber = presetNumber }; - var handler = PresetReceived; + var handler = PresetReceived; try { - if (!UrlBase.StartsWith("https")) - return; - var req = new HttpsClientRequest(); - req.Url = new UrlParser(UrlBase + "/api/presets/userandroom"); - req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; - req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); - req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); - req.ContentString = JsonConvert.SerializeObject(msg); + if (!UrlBase.StartsWith("https")) + return; + var req = new HttpsClientRequest(); + req.Url = new UrlParser(UrlBase + "/api/presets/userandroom"); + req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; + req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); + req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); + req.ContentString = JsonConvert.SerializeObject(msg); - var client = new HttpsClient(); - client.HostVerification = false; - client.PeerVerification = false; + var client = new HttpsClient(); + client.HostVerification = false; + client.PeerVerification = false; - // ask for the preset - var resp = client.Dispatch(req); - if (resp.Code == 200) // got it - { - //Debug.Console(1, this, "Received: {0}", resp.ContentString); - var preset = JsonConvert.DeserializeObject(resp.ContentString); - CurrentPreset = preset; + // ask for the preset + var resp = client.Dispatch(req); + if (resp.Code == 200) // got it + { + //Debug.Console(1, this, "Received: {0}", resp.ContentString); + var preset = JsonConvert.DeserializeObject(resp.ContentString); + CurrentPreset = preset; - //if there's no preset data, load the template - if (preset.Data == null || preset.Data.Trim() == string.Empty || JObject.Parse(preset.Data).Count == 0) - { - //Debug.Console(1, this, "Loaded preset has no data. Loading default template."); - LoadDefaultPresetData(); - return; - } + //if there's no preset data, load the template + if (preset.Data == null || preset.Data.Trim() == string.Empty || JObject.Parse(preset.Data).Count == 0) + { + //Debug.Console(1, this, "Loaded preset has no data. Loading default template."); + LoadDefaultPresetData(); + return; + } - J2SMaster.LoadWithJson(preset.Data); - if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(preset, true)); - } - else // no existing preset - { - CurrentPreset = new Preset(); - LoadDefaultPresetData(); - if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(null, false)); - } + J2SMaster.LoadWithJson(preset.Data); + if (handler != null) + handler(this, new PresetReceivedEventArgs(preset, true)); + } + else // no existing preset + { + CurrentPreset = new Preset(); + LoadDefaultPresetData(); + if (handler != null) + handler(this, new PresetReceivedEventArgs(null, false)); + } } catch (HttpException e) { var resp = e.Response; Debug.Console(1, this, "No preset received (code {0}). Loading default template", resp.Code); LoadDefaultPresetData(); - if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(null, false)); + if (handler != null) + PresetReceived(this, new PresetReceivedEventArgs(null, false)); } } @@ -218,14 +219,14 @@ namespace PepperDash.Core.WebApi.Presets /// /// /// - /// - /// SavePresetForThisUser method - /// + /// + /// SavePresetForThisUser method + /// public void SavePresetForThisUser(int roomTypeId, int presetNumber) { if (CurrentPreset == null) LoadDefaultPresetData(); - //return; + //return; //// A new preset needs to have its numbers set //if (CurrentPreset.IsNewPreset) @@ -245,8 +246,8 @@ namespace PepperDash.Core.WebApi.Presets { CurrentPreset.Data = json; - if (!UrlBase.StartsWith("https")) - return; + if (!UrlBase.StartsWith("https")) + return; var req = new HttpsClientRequest(); req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; req.Url = new UrlParser(string.Format("{0}/api/presets/addorchange", UrlBase)); @@ -255,8 +256,8 @@ namespace PepperDash.Core.WebApi.Presets req.ContentString = JsonConvert.SerializeObject(CurrentPreset); var client = new HttpsClient(); - client.HostVerification = false; - client.PeerVerification = false; + client.HostVerification = false; + client.PeerVerification = false; try { var resp = client.Dispatch(req); diff --git a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs index 298bbc9b..a5aa0d13 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs @@ -39,6 +39,8 @@ namespace PepperDash.Essentials.Core ICec Port; + bool _cecSubscribed; + /// /// Constructor /// @@ -54,7 +56,7 @@ namespace PepperDash.Essentials.Core { Port = postActivationFunc(config); - Port.StreamCec.CecChange += StreamCec_CecChange; + TryEnsureCecSubscription(); }); } @@ -68,7 +70,29 @@ namespace PepperDash.Essentials.Core { Port = port; + TryEnsureCecSubscription(); + } + + /// + /// Subscribes to the CEC change event once is available. + /// Safe to call repeatedly; the subscription is only wired a single time. + /// If StreamCec is null during construction, this is retried when send methods invoke + /// this method later. + /// + void TryEnsureCecSubscription() + { + if (_cecSubscribed) + return; + + if (Port?.StreamCec == null) + { + Debug.LogMessage(LogEventLevel.Warning, this, "StreamCec is not available; CEC feedback is deferred until the device is ready"); + return; + } + Port.StreamCec.CecChange += new CecChangeEventHandler(StreamCec_CecChange); + _cecSubscribed = true; + Debug.LogMessage(LogEventLevel.Information, this, "Subscribed to CEC feedback"); } void StreamCec_CecChange(Cec cecDevice, CecEventArgs args) @@ -104,8 +128,9 @@ namespace PepperDash.Essentials.Core /// public void SendText(string text) { - if (Port == null) + if (Port?.StreamCec == null) return; + TryEnsureCecSubscription(); this.PrintSentText(text); Port.StreamCec.Send.StringValue = text; } @@ -115,8 +140,9 @@ namespace PepperDash.Essentials.Core /// public void SendBytes(byte[] bytes) { - if (Port == null) + if (Port?.StreamCec == null) return; + TryEnsureCecSubscription(); var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); this.PrintSentBytes(bytes); Debug.LogMessage(LogEventLevel.Information, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes)); diff --git a/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs b/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs index 9318e04b..b28331a0 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs @@ -96,6 +96,17 @@ namespace PepperDash.Essentials.Core comm = udp; break; } + case eControlMethod.UdpClient: + { + var udpClient = new GenericUdpClient(deviceConfig.Key + "-udpClient", c.Address, c.Port, c.BufferSize) + { + AutoReconnect = c.AutoReconnect + }; + if (udpClient.AutoReconnect) + udpClient.AutoReconnectIntervalMs = c.AutoReconnectIntervalMs; + comm = udpClient; + break; + } case eControlMethod.Telnet: break; case eControlMethod.SecureTcpIp: diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs index 40c6c0cf..37cd4eba 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs @@ -135,12 +135,14 @@ namespace PepperDash.Essentials.Core.Config { var parsedConfig = JObject.Parse(fs.ReadToEnd()); - // Check if it's a v2 config (check for "version" node) - // this means it's already merged by the Portal API - // from the v2 config tool - var isV2Config = parsedConfig["versions"] != null; - - if (isV2Config) + // A config is v1 if it has separate "system" and "template" nodes that + // need to be merged. A v2 config is already merged by the Portal API and + // will not have "system"/"template" nodes. This is independent of whether + // a "versions" node is present, which only carries version metadata and + // can appear on either a v1 or v2 config. + var isV1Config = parsedConfig["system"] != null && parsedConfig["template"] != null; + + if (!isV1Config) { Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary."); ConfigObject = parsedConfig.ToObject(); @@ -148,6 +150,8 @@ namespace PepperDash.Essentials.Core.Config return true; } + Debug.LogMessage(LogEventLevel.Information, "Config file is a v1 format, merging system and template."); + // Extract SystemUrl and TemplateUrl into final config output ConfigObject = PortalConfigReader.MergeConfigs(parsedConfig).ToObject(); @@ -160,6 +164,13 @@ namespace PepperDash.Essentials.Core.Config { ConfigObject.TemplateUrl = parsedConfig["template_url"].Value(); } + + // MergeConfigs does not carry the "versions" node forward, so it must be + // applied separately to ensure it's preserved in the merged config. + if (parsedConfig["versions"] != null) + { + ConfigObject.Versions = parsedConfig["versions"].ToObject(); + } } Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config"); diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs index 9df673bf..2ba33df0 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs @@ -19,6 +19,7 @@ namespace PepperDash.Essentials.Core.Config /// /// ConfigUpdater class /// + [Obsolete("ConfigUpdater is no longer supported and will be removed in a future release.")] public static class ConfigUpdater { /// @@ -81,7 +82,7 @@ namespace PepperDash.Essentials.Core.Config { var handler = ConfigStatusChanged; - if(handler != null) + if (handler != null) { handler(typeof(ConfigUpdater), new ConfigStatusEventArgs(status)); } @@ -89,7 +90,7 @@ namespace PepperDash.Essentials.Core.Config static void WriteConfigToFile(string configData) { - var filePath = Global.FilePathPrefix+ "configurationFile-updated.json"; + var filePath = Global.FilePathPrefix + "configurationFile-updated.json"; try { @@ -104,7 +105,7 @@ namespace PepperDash.Essentials.Core.Config Debug.LogMessage(LogEventLevel.Debug, "Error parsing new config: {0}", e); OnStatusUpdate(eUpdateStatus.UpdateFailed); - } + } } /// @@ -149,11 +150,11 @@ namespace PepperDash.Essentials.Core.Config // Directory exists, first clear any contents var archivedConfigFiles = ConfigReader.GetConfigFiles(archiveDirectoryPath + Global.DirectorySeparator + Global.ConfigFileName + ".bak"); - if(archivedConfigFiles != null || archivedConfigFiles.Length > 0) + if (archivedConfigFiles != null && archivedConfigFiles.Length > 0) { Debug.LogMessage(LogEventLevel.Information, "{0} Existing files found in archive folder. Deleting.", archivedConfigFiles.Length); - for (int i = 0; i < archivedConfigFiles.Length; i++ ) + for (int i = 0; i < archivedConfigFiles.Length; i++) { var file = archivedConfigFiles[i]; Debug.LogMessage(LogEventLevel.Information, "Deleting archived file: '{0}'", file.FullName); @@ -170,9 +171,9 @@ namespace PepperDash.Essentials.Core.Config // Moves the file and appends the .bak extension var fileDest = archiveDirectoryPath + "/" + file.Name + ".bak"; - if(!File.Exists(fileDest)) + if (!File.Exists(fileDest)) { - file.MoveTo(fileDest); + file.MoveTo(fileDest); } else Debug.LogMessage(LogEventLevel.Information, "Cannot move file to archive folder. Existing file already exists with same name: '{0}'", fileDest); @@ -207,15 +208,15 @@ namespace PepperDash.Essentials.Core.Config CrestronConsole.SendControlSystemCommand(string.Format("progreset -p:{0}", InitialParametersClass.ApplicationNumber), ref response); - Debug.LogMessage(LogEventLevel.Debug, "Console Response: {0}", response); + Debug.LogMessage(LogEventLevel.Debug, "Console Response: {0}", response); } } - /// - /// Enumeration of eUpdateStatus values - /// - public enum eUpdateStatus + /// + /// Enumeration of eUpdateStatus values + /// + public enum eUpdateStatus { /// /// UpdateStarted status diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs index 6ffe07d2..44275ef0 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs @@ -105,6 +105,7 @@ namespace PepperDash.Essentials.Core.Config /// /// Gets or sets the Versions /// + [JsonProperty("versions")] public VersionData Versions { get; set; } /// @@ -134,12 +135,25 @@ namespace PepperDash.Essentials.Core.Config [JsonProperty("packages")] public List Packages { get; set; } + /// + /// Gets or sets the touchpanel wrapper app version + /// + [JsonProperty("touchpanelWrapperApp")] + public NugetVersion TouchpanelWrapperApp { get; set; } + + /// + /// Gets or sets the list of user interface packages + /// + [JsonProperty("userInterfaces")] + public List UserInterfaces { get; set; } + /// /// Initializes a new instance of the class. /// public VersionData() { Packages = new List(); + UserInterfaces = new List(); } } @@ -157,8 +171,20 @@ namespace PepperDash.Essentials.Core.Config /// /// Gets or sets the PackageId /// - [JsonProperty("packageId")] + [JsonProperty("packageId", NullValueHandling = NullValueHandling.Ignore)] public string PackageId { get; set; } + + /// + /// Gets or sets the RepoUrl + /// + [JsonProperty("repoUrl", NullValueHandling = NullValueHandling.Ignore)] + public string RepoUrl { get; set; } + + /// + /// Gets or sets the human-readable name + /// + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get; set; } } /// diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs new file mode 100644 index 00000000..6cd3835e --- /dev/null +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs @@ -0,0 +1,48 @@ +using System; + +namespace PepperDash.Essentials.Core.DeviceTypeInterfaces +{ + /// + /// Defines the contract for a wireless presentation endpoint that reports whether a wireless + /// sharing session is currently active. Implemented by platforms such as Crestron AirMedia, + /// Mersive Solstice, Barco ClickShare, Miracast/Teams receivers, etc. Allows consumers (e.g. + /// room plugins) to react to wireless sharing activity without taking a dependency on any + /// concrete device implementation. + /// + /// + /// This refers specifically to wireless screen/device mirroring, as distinct from in-call + /// content sharing on a video conference. + /// + public interface IHasWirelessSharing + { + /// + /// Reports whether a wireless sharing session is currently active (content is being presented). + /// + BoolFeedback IsSharingFeedback { get; } + + /// + /// Raised when wireless sharing starts or stops. The event args carry the new sharing state. + /// + event EventHandler SharingChanged; + } + + /// + /// Event arguments describing a change in wireless sharing state. + /// + public class WirelessSharingEventArgs : EventArgs + { + /// + /// True if a wireless sharing session is active (content is being presented), false otherwise. + /// + public bool IsSharing { get; private set; } + + /// + /// Creates a new . + /// + /// True if a wireless sharing session is active, false otherwise. + public WirelessSharingEventArgs(bool isSharing) + { + IsSharing = isSharing; + } + } +} diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs index 887f1789..8603d5b6 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs @@ -1,10 +1,12 @@ +using System; using PepperDash.Core; namespace PepperDash.Essentials.Core.DeviceTypeInterfaces { /// - /// Defines the contract for IMobileControlMessenger + /// Obsolete: messengers are subscription based by default; use IMobileControlMessenger instead. /// + [Obsolete("This interface is obsolete and will be removed in a future version. All messengers are now subscription based.")] public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger { /// diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs new file mode 100644 index 00000000..6c6e5598 --- /dev/null +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -0,0 +1,122 @@ +using System; + +namespace PepperDash.Essentials.Core.DeviceTypeInterfaces +{ + /// + /// Interface for network switches that support VLAN assignment on individual ports. + /// + public interface INetworkSwitchVlanManager + { + /// + /// Returns the current access VLAN ID configured on the port. + /// Return -1 when the value is unavailable (e.g. the switch has not been polled yet + /// or the implementation does not support VLAN queries). + /// + /// Switch port identifier + /// VLAN ID or -1 when unavailable + int GetPortCurrentVlan(string port); + + /// + /// Changes the access VLAN of a single switch port. + /// The implementation is responsible for entering/exiting privileged/config mode. + /// + /// Switch port identifier (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco) + /// Target VLAN ID (1-4093) + void SetPortVlan(string port, uint vlanId); + } + + /// + /// Interface for network switches that support Power over Ethernet (PoE) control on individual ports. + /// + public interface INetworkSwitchPoeManager + { + /// + /// Enables or disables PoE power delivery on a single switch port. + /// The implementation is responsible for entering/exiting privileged/config mode. + /// + /// Switch port identifier + /// True to enable PoE; false to disable PoE + void SetPortPoeState(string port, bool enabled); + } + + /// + /// Standardized interface for network switch devices that support per-port PoE control + /// and VLAN assignment. + /// + public interface INetworkSwitchPoeVlanManager : INetworkSwitchVlanManager, INetworkSwitchPoeManager + { + /// + /// Event that is raised when the state of a switch port changes, such as a VLAN change or PoE state change. + /// + event EventHandler PortStateChanged; + + } + + /// + /// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes. + /// + public class NetworkSwitchPortEventArgs : EventArgs + { + /// + /// The identifier of the port that changed state (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco). + /// + public string Port { get; private set; } + + /// + /// The type of event that occurred on the port (e.g. VLAN change, PoE enabled/disabled). + /// + public NetworkSwitchPortEventType EventType { get; private set; } + + /// + /// Constructor for NetworkSwitchPortEventArgs + /// + /// The identifier of the port that changed state + /// The type of event that occurred on the port + public NetworkSwitchPortEventArgs(string port, NetworkSwitchPortEventType eventType) + { + Port = port; + EventType = eventType; + } + } + + /// + /// Enumeration of network switch port state change event types (e.g. VLAN changes or PoE state changes). + /// + public enum NetworkSwitchPortEventType + { + /// + /// Indicates that the type of event is unknown or cannot be determined. + /// + Unknown, + + /// + /// Indicates that a VLAN change is in progress on the port, either through a call to SetPortVlan or an external change detected by polling. + /// + VlanChangeInProgress, + + /// + /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan or an external change detected by polling. + /// + VlanChanged, + + /// + /// Indicates that PoE is being disabled on the port, either through a call to SetPortPoeState or an external change detected by polling. + /// + PoeDisableInProgress, + + /// + /// Indicates that PoE has been disabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling. + /// + PoEDisabled, + + /// + /// Indicates that PoE is being enabled on the port, either through a call to SetPortPoeState or an external change detected by polling. + /// + PoeEnableInProgress, + + /// + /// Indicates that PoE has been enabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling. + /// + PoEEnabled + } +} diff --git a/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs b/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs index c4ec2ca9..c01d5e81 100644 --- a/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs +++ b/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs @@ -7,6 +7,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace PepperDash.Essentials.Core @@ -176,7 +177,20 @@ namespace PepperDash.Essentials.Core { if (!conversionType.IsEnum) { - return Convert.ChangeType(value, conversionType, System.Globalization.CultureInfo.InvariantCulture); + if (conversionType == typeof(byte[]) && value is string byteString) + { + var unescaped = UnescapeString(byteString); + return System.Text.Encoding.GetEncoding(28591).GetBytes(unescaped); + } + + var converted = Convert.ChangeType(value, conversionType, System.Globalization.CultureInfo.InvariantCulture); + + if (conversionType == typeof(string) && converted is string s) + { + return UnescapeString(s); + } + + return converted; } var stringValue = Convert.ToString(value); @@ -189,6 +203,32 @@ namespace PepperDash.Essentials.Core return Enum.Parse(conversionType, stringValue, true); } + /// + /// Processes escape sequences in a string, converting sequences like \r, \n, \t, \xHH + /// to their corresponding non-printable ASCII characters. + /// + private static string UnescapeString(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + return Regex.Replace(input, @"\\(r|n|t|\\|x[0-9A-Fa-f]{2})", match => + { + var seq = match.Groups[1].Value; + switch (seq) + { + case "r": return "\r"; + case "n": return "\n"; + case "t": return "\t"; + case "\\": return "\\"; + default: + // \xHH hex escape + var hex = seq.Substring(1); + return ((char)Convert.ToInt32(hex, 16)).ToString(); + } + }); + } + /// /// Gets the properties on a device /// diff --git a/src/PepperDash.Essentials.Core/Devices/DeviceManager.cs b/src/PepperDash.Essentials.Core/Devices/DeviceManager.cs index c199773a..f32fe84e 100644 --- a/src/PepperDash.Essentials.Core/Devices/DeviceManager.cs +++ b/src/PepperDash.Essentials.Core/Devices/DeviceManager.cs @@ -436,14 +436,14 @@ namespace PepperDash.Essentials.Core CrestronConsole.ConsoleCommandResponse("Device {0} has {1} Input Ports:{2}", s, inputPorts.Count, CrestronEnvironment.NewLine); foreach (var routingInputPort in inputPorts) { - CrestronConsole.ConsoleCommandResponse("{0}{1}", routingInputPort.Key, CrestronEnvironment.NewLine); + CrestronConsole.ConsoleCommandResponse("key: {0} signalType: {1}{2}", routingInputPort.Key, routingInputPort.Type, CrestronEnvironment.NewLine); } } if (outputPorts == null) return; CrestronConsole.ConsoleCommandResponse("Device {0} has {1} Output Ports:{2}", s, outputPorts.Count, CrestronEnvironment.NewLine); foreach (var routingOutputPort in outputPorts) { - CrestronConsole.ConsoleCommandResponse("{0}{1}", routingOutputPort.Key, CrestronEnvironment.NewLine); + CrestronConsole.ConsoleCommandResponse("key: {0} signalType: {1}{2}", routingOutputPort.Key, routingOutputPort.Type, CrestronEnvironment.NewLine); } } diff --git a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs new file mode 100644 index 00000000..c628129e --- /dev/null +++ b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs @@ -0,0 +1,14 @@ +namespace PepperDash.Essentials.Core +{ + /// + /// Defines the contract for IHasDspPresetSave + /// + public interface IHasDspPresetSave : IDspPresets + { + /// + /// Saves the preset by key + /// + /// key of preset to save + void SavePreset(string key); + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs b/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs index d55194fd..b41a9020 100644 --- a/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs +++ b/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs @@ -7,9 +7,10 @@ using PepperDash.Core; namespace PepperDash.Essentials.Core.Interfaces { - /// - /// Defines the contract for ILogStrings - /// + /// + /// Defines the contract for ILogStrings + /// + [Obsolete("ILogStrings is no longer supported and will be removed in a future release.")] public interface ILogStrings : IKeyed { /// diff --git a/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs b/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs index bb835919..32ef104a 100644 --- a/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs +++ b/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs @@ -7,15 +7,16 @@ using PepperDash.Core; namespace PepperDash.Essentials.Core.Interfaces { - /// - /// Defines the contract for ILogStringsWithLevel - /// + /// + /// Defines the contract for ILogStringsWithLevel + /// + [Obsolete("ILogStringsWithLevel is no longer supported and will be removed in a future release.")] public interface ILogStringsWithLevel : IKeyed { /// /// Defines a class that is capable of logging a string with an int level /// - void SendToLog(IKeyed device, Debug.ErrorLogLevel level,string logMessage); + void SendToLog(IKeyed device, Debug.ErrorLogLevel level, string logMessage); } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj index 251ba316..b7f333bd 100644 --- a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj +++ b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj @@ -25,7 +25,7 @@ bin\$(Configuration)\PepperDash_Essentials_Core.xml - + diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 3797f5e2..303a0194 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -2,10 +2,8 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using Crestron.SimplSharpPro.Keypads; using PepperDash.Essentials.Core.Queues; using PepperDash.Essentials.Core.Routing; -using Serilog.Events; using Debug = PepperDash.Core.Debug; @@ -18,6 +16,20 @@ namespace PepperDash.Essentials.Core /// public static class Extensions { + + /// + /// A collection of RouteDescriptors for each signal type. + /// + public static readonly Dictionary RouteDescriptors = new Dictionary() + { + { eRoutingSignalType.Audio, new RouteDescriptorCollection() }, + { eRoutingSignalType.Video, new RouteDescriptorCollection() }, + { eRoutingSignalType.SecondaryAudio, new RouteDescriptorCollection() }, + { eRoutingSignalType.AudioVideo, new RouteDescriptorCollection() }, + { eRoutingSignalType.UsbInput, new RouteDescriptorCollection() }, + { eRoutingSignalType.UsbOutput, new RouteDescriptorCollection() } + }; + /// /// Stores pending route requests, keyed by the destination device key. /// Used primarily to handle routing requests while a device is cooling down. @@ -29,6 +41,78 @@ namespace PepperDash.Essentials.Core /// private static readonly GenericQueue routeRequestQueue = new GenericQueue("routingQueue"); + /// + /// Indexed lookup of TieLines by destination device key for faster queries. + /// + private static Dictionary> _tieLinesByDestination; + + /// + /// Indexed lookup of TieLines by source device key for faster queries. + /// + private static Dictionary> _tieLinesBySource; + + /// + /// Indexes all TieLines by source and destination device keys for faster lookups. + /// Should be called once at system startup after all TieLines are created. + /// + public static void IndexTieLines() + { + try + { + Debug.LogInformation("Indexing TieLines for faster route discovery"); + + _tieLinesByDestination = TieLineCollection.Default + .GroupBy(t => t.DestinationPort.ParentDevice.Key) + .ToDictionary(g => g.Key, g => g.ToList()); + + _tieLinesBySource = TieLineCollection.Default + .GroupBy(t => t.SourcePort.ParentDevice.Key) + .ToDictionary(g => g.Key, g => g.ToList()); + + Debug.LogInformation("TieLine indexing complete. {0} destination keys, {1} source keys", + _tieLinesByDestination.Count, _tieLinesBySource.Count); + } + catch (Exception ex) + { + Debug.LogError("Exception indexing TieLines: {exception}", ex.Message); + Debug.LogDebug(ex, "Stack Trace: "); + } + } + + /// + /// Gets TieLines connected to a destination device. + /// Uses indexed lookup if available, otherwise falls back to LINQ query. + /// + /// The destination device key + /// List of TieLines connected to the destination + private static IEnumerable GetTieLinesForDestination(string destinationKey) + { + if (_tieLinesByDestination != null && _tieLinesByDestination.TryGetValue(destinationKey, out List tieLines)) + { + return tieLines; + } + + // Fallback to LINQ if index not available + return TieLineCollection.Default.Where(t => t.DestinationPort.ParentDevice.Key == destinationKey); + } + + /// + /// Gets TieLines connected to a source device. + /// Uses indexed lookup if available, otherwise falls back to LINQ query. + /// + /// The source device key + /// List of TieLines connected to the source + private static IEnumerable GetTieLinesForSource(string sourceKey) + { + if (_tieLinesBySource != null && _tieLinesBySource.TryGetValue(sourceKey, out List tieLines)) + { + return tieLines; + } + + // Fallback to LINQ if index not available + return TieLineCollection.Default.Where(t => t.SourcePort.ParentDevice.Key == sourceKey); + } + /// /// Gets any existing RouteDescriptor for a destination, clears it using ReleaseRoute /// and then attempts a new Route and if sucessful, stores that RouteDescriptor @@ -38,7 +122,7 @@ namespace PepperDash.Essentials.Core { // Remove this line before committing!!!!! var frame = new StackFrame(1, true); - Debug.LogMessage(LogEventLevel.Information, "ReleaseAndMakeRoute Called from {method} with params {destinationKey}:{sourceKey}:{signalType}:{destinationPortKey}:{sourcePortKey}", frame.GetMethod().Name, destination.Key, source.Key, signalType.ToString(), destinationPortKey, sourcePortKey); + Debug.LogInformation("ReleaseAndMakeRoute Called from {method} with params {destinationKey}:{sourceKey}:{signalType}:{destinationPortKey}:{sourcePortKey}", frame.GetMethod().Name, destination.Key, source.Key, signalType.ToString(), destinationPortKey, sourcePortKey); var inputPort = string.IsNullOrEmpty(destinationPortKey) ? null : destination.InputPorts.FirstOrDefault(p => p.Key == destinationPortKey); var outputPort = string.IsNullOrEmpty(sourcePortKey) ? null : source.OutputPorts.FirstOrDefault(p => p.Key == sourcePortKey); @@ -96,13 +180,13 @@ namespace PepperDash.Essentials.Core /// destination device key public static void RemoveRouteRequestForDestination(string destinationKey) { - Debug.LogMessage(LogEventLevel.Information, "Removing route request for {destination}", null, destinationKey); + Debug.LogInformation("Removing route request for {destination}", destinationKey); var result = RouteRequests.Remove(destinationKey); var messageTemplate = result ? "Route Request for {destination} removed" : "Route Request for {destination} not found"; - Debug.LogMessage(LogEventLevel.Information, messageTemplate, null, destinationKey); + Debug.LogInformation(messageTemplate, destinationKey); } /// @@ -118,8 +202,8 @@ namespace PepperDash.Essentials.Core if (!signalType.HasFlag(eRoutingSignalType.AudioVideo) && !(signalType.HasFlag(eRoutingSignalType.Video) && signalType.HasFlag(eRoutingSignalType.SecondaryAudio))) { - var singleTypeRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, signalType); - Debug.LogMessage(LogEventLevel.Debug, "Attempting to build source route from {sourceKey} of type {type}", destination, source.Key, signalType); + var singleTypeRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, signalType); + Debug.LogDebug(destination, "Attempting to build source route from {sourceKey} of type {type}", source.Key, signalType); if (!destination.GetRouteToSource(source, null, null, signalType, 0, singleTypeRouteDescriptor, destinationPort, sourcePort)) singleTypeRouteDescriptor = null; @@ -127,54 +211,55 @@ namespace PepperDash.Essentials.Core var routes = singleTypeRouteDescriptor?.Routes ?? new List(); foreach (var route in routes) { - Debug.LogMessage(LogEventLevel.Verbose, "Route for device: {route}", destination, route.ToString()); + Debug.LogVerbose(destination, "Route for device: {route}", route.ToString()); } return (singleTypeRouteDescriptor, null); } // otherwise, audioVideo needs to be handled as two steps. - Debug.LogMessage(LogEventLevel.Debug, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", destination, source.Key, signalType); + Debug.LogDebug(destination, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", destination.Key, source.Key, signalType); RouteDescriptor audioRouteDescriptor; if (signalType.HasFlag(eRoutingSignalType.SecondaryAudio)) { - audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.SecondaryAudio); + audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.SecondaryAudio); } else { - audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.Audio); + audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.Audio); } var audioSuccess = destination.GetRouteToSource(source, null, null, signalType.HasFlag(eRoutingSignalType.SecondaryAudio) ? eRoutingSignalType.SecondaryAudio : eRoutingSignalType.Audio, 0, audioRouteDescriptor, destinationPort, sourcePort); if (!audioSuccess) - Debug.LogMessage(LogEventLevel.Debug, "Cannot find audio route to {0}", destination, source.Key); + Debug.LogDebug(destination, "Cannot find audio route to {0}", source.Key); - var videoRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.Video); + var videoRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.Video); var videoSuccess = destination.GetRouteToSource(source, null, null, eRoutingSignalType.Video, 0, videoRouteDescriptor, destinationPort, sourcePort); if (!videoSuccess) - Debug.LogMessage(LogEventLevel.Debug, "Cannot find video route to {0}", destination, source.Key); + Debug.LogDebug(destination, "Cannot find video route to {0}", source.Key); foreach (var route in audioRouteDescriptor.Routes) { - Debug.LogMessage(LogEventLevel.Verbose, "Audio route for device: {route}", destination, route.ToString()); + Debug.LogVerbose(destination, "Audio route for device: {route}", route.ToString()); } foreach (var route in videoRouteDescriptor.Routes) { - Debug.LogMessage(LogEventLevel.Verbose, "Video route for device: {route}", destination, route.ToString()); + Debug.LogVerbose(destination, "Video route for device: {route}", route.ToString()); } if (!audioSuccess && !videoSuccess) return (null, null); - - return (audioRouteDescriptor, videoRouteDescriptor); + // Return null for descriptors that have no routes + return (audioSuccess && audioRouteDescriptor.Routes.Count > 0 ? audioRouteDescriptor : null, + videoSuccess && videoRouteDescriptor.Routes.Count > 0 ? videoRouteDescriptor : null); } /// @@ -190,8 +275,8 @@ namespace PepperDash.Essentials.Core { if (destination == null) throw new ArgumentNullException(nameof(destination)); if (source == null) throw new ArgumentNullException(nameof(source)); - if (destinationPort == null) Debug.LogMessage(LogEventLevel.Information, "Destination port is null"); - if (sourcePort == null) Debug.LogMessage(LogEventLevel.Information, "Source port is null"); + if (destinationPort == null) Debug.LogDebug("Destination port is null"); + if (sourcePort == null) Debug.LogDebug("Source port is null"); var routeRequest = new RouteRequest { @@ -213,7 +298,7 @@ namespace PepperDash.Essentials.Core RouteRequests[destination.Key] = routeRequest; - Debug.LogMessage(LogEventLevel.Information, "Device: {destination} is cooling down and already has a routing request stored. Storing new route request to route to source key: {sourceKey}", null, destination.Key, routeRequest.Source.Key); + Debug.LogInformation("Device: {destination} is cooling down and already has a routing request stored. Storing new route request to route to source key: {sourceKey}", destination.Key, routeRequest.Source.Key); return; } @@ -225,7 +310,7 @@ namespace PepperDash.Essentials.Core RouteRequests.Add(destination.Key, routeRequest); - Debug.LogMessage(LogEventLevel.Information, "Device: {destination} is cooling down. Storing route request to route to source key: {sourceKey}", null, destination.Key, routeRequest.Source.Key); + Debug.LogInformation("Device: {destination} is cooling down. Storing route request to route to source key: {sourceKey}", destination.Key, routeRequest.Source.Key); return; } @@ -237,7 +322,7 @@ namespace PepperDash.Essentials.Core RouteRequests.Remove(destination.Key); - Debug.LogMessage(LogEventLevel.Information, "Device: {destination} is NOT cooling down. Removing stored route request and routing to source key: {sourceKey}", null, destination.Key, routeRequest.Source.Key); + Debug.LogInformation("Device: {destination} is NOT cooling down. Removing stored route request and routing to source key: {sourceKey}", destination.Key, routeRequest.Source.Key); } routeRequestQueue.Enqueue(new ReleaseRouteQueueItem(ReleaseRouteInternal, destination, destinationPort?.Key ?? string.Empty, false)); @@ -245,6 +330,104 @@ namespace PepperDash.Essentials.Core routeRequestQueue.Enqueue(new RouteRequestQueueItem(RunRouteRequest, routeRequest)); } + /// + /// Maps destination input ports to source output ports for all routing devices. + /// + public static void MapDestinationsToSources() + { + try + { + // Index TieLines before mapping if not already done + if (_tieLinesByDestination == null || _tieLinesBySource == null) + { + IndexTieLines(); + } + + var sinks = DeviceManager.AllDevices.OfType() + .Where(d => !(d is IRoutingInputsOutputs)).ToList(); + var sources = DeviceManager.AllDevices.OfType() + .Where(d => !(d is IRoutingInputsOutputs)).ToList(); + + foreach (var sink in sinks) + { + foreach (var source in sources) + { + foreach (var inputPort in sink.InputPorts) + { + foreach (var outputPort in source.OutputPorts) + { + var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, outputPort.Type, inputPort, outputPort); + + if (audioOrSingleRoute == null && videoRoute == null) + { + continue; + } + + Debug.LogVerbose("AudioOrSingleRoute Found: {audioRoute}", audioOrSingleRoute); + + Debug.LogVerbose("VideoRoute Found: {videoRoute}", videoRoute); + + if (audioOrSingleRoute != null) + { + // Only add routes that have actual switching steps + if (audioOrSingleRoute.Routes == null || audioOrSingleRoute.Routes.Count == 0) + { + continue; + } + + Debug.LogVerbose("Route mapped: {source} -> {sink} via {input}/{output}, type {type}", + source.Key, sink.Key, + inputPort.Key, outputPort.Key, audioOrSingleRoute.SignalType); + + // Add to the appropriate collection(s) based on signal type + // Note: A single route descriptor with combined flags (e.g., AudioVideo) will be added once per matching signal type + if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Audio)) + { + RouteDescriptors[eRoutingSignalType.Audio].AddRouteDescriptor(audioOrSingleRoute); + } + if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Video)) + { + RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(audioOrSingleRoute); + } + if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.SecondaryAudio)) + { + RouteDescriptors[eRoutingSignalType.SecondaryAudio].AddRouteDescriptor(audioOrSingleRoute); + } + if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.UsbInput)) + { + RouteDescriptors[eRoutingSignalType.UsbInput].AddRouteDescriptor(audioOrSingleRoute); + } + if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.UsbOutput)) + { + RouteDescriptors[eRoutingSignalType.UsbOutput].AddRouteDescriptor(audioOrSingleRoute); + } + } + if (videoRoute != null) + { + // Only add routes that have actual switching steps + if (videoRoute.Routes == null || videoRoute.Routes.Count == 0) + { + continue; + } + + Debug.LogVerbose("Video route mapped: {source} -> {sink} via {input}/{output}", + source.Key, sink.Key, + inputPort.Key, outputPort.Key); + + RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(videoRoute); + } + } + } + } + } + } + catch (Exception ex) + { + Debug.LogError("Exception mapping routes: {exception}", ex.Message); + Debug.LogDebug(ex, "Stack Trace: "); + } + } + /// /// Executes the actual routing based on a . /// Finds the route path, adds it to the collection, and executes the switches. @@ -257,7 +440,54 @@ namespace PepperDash.Essentials.Core if (request.Source == null) return; - var (audioOrSingleRoute, videoRoute) = request.Destination.GetRouteToSource(request.Source, request.SignalType, request.DestinationPort, request.SourcePort); + RouteDescriptor audioOrSingleRoute = null; + RouteDescriptor videoRoute = null; + + // Try to use pre-loaded route descriptors first + if (request.SignalType.HasFlag(eRoutingSignalType.AudioVideo)) + { + // For AudioVideo routes, check both Audio and Video collections + if (RouteDescriptors.TryGetValue(eRoutingSignalType.Audio, out RouteDescriptorCollection audioCollection)) + { + audioOrSingleRoute = audioCollection.Descriptors.FirstOrDefault(d => + d.Source.Key == request.Source.Key && + d.Destination.Key == request.Destination.Key && + (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key) && + (request.SourcePort == null || d.OutputPort?.Key == request.SourcePort.Key)); + } + + if (RouteDescriptors.TryGetValue(eRoutingSignalType.Video, out RouteDescriptorCollection videoCollection)) + { + videoRoute = videoCollection.Descriptors.FirstOrDefault(d => + d.Source.Key == request.Source.Key && + d.Destination.Key == request.Destination.Key && + (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key) && + (request.SourcePort == null || d.OutputPort?.Key == request.SourcePort.Key)); + } + } + else + { + // For single signal type routes + var signalTypeToCheck = request.SignalType.HasFlag(eRoutingSignalType.SecondaryAudio) + ? eRoutingSignalType.SecondaryAudio + : request.SignalType; + + if (RouteDescriptors.TryGetValue(signalTypeToCheck, out RouteDescriptorCollection collection)) + { + audioOrSingleRoute = collection.Descriptors.FirstOrDefault(d => + d.Source.Key == request.Source.Key && + d.Destination.Key == request.Destination.Key && + (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key) && + (request.SourcePort == null || d.OutputPort?.Key == request.SourcePort.Key)); + } + } + + // If no pre-loaded route found, build it dynamically + if (audioOrSingleRoute == null && videoRoute == null) + { + Debug.LogDebug(request.Destination, "No pre-loaded route found, building dynamically"); + (audioOrSingleRoute, videoRoute) = request.Destination.GetRouteToSource(request.Source, request.SignalType, request.DestinationPort, request.SourcePort); + } if (audioOrSingleRoute == null && videoRoute == null) return; @@ -269,14 +499,15 @@ namespace PepperDash.Essentials.Core RouteDescriptorCollection.DefaultCollection.AddRouteDescriptor(videoRoute); } - Debug.LogMessage(LogEventLevel.Verbose, "Executing full route", request.Destination); + Debug.LogVerbose(request.Destination, "Executing full route"); audioOrSingleRoute.ExecuteRoutes(); videoRoute?.ExecuteRoutes(); } catch (Exception ex) { - Debug.LogMessage(ex, "Exception Running Route Request {request}", null, request); + Debug.LogError("Exception Running Route Request {request}: {exception}", request, ex.Message); + Debug.LogDebug(ex, "Stack Trace: "); } } @@ -290,7 +521,7 @@ namespace PepperDash.Essentials.Core { try { - Debug.LogMessage(LogEventLevel.Information, "Release route for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); + Debug.LogInformation(destination, "Release route for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); if (RouteRequests.TryGetValue(destination.Key, out RouteRequest existingRequest) && destination is IWarmingCooling) { @@ -304,13 +535,14 @@ namespace PepperDash.Essentials.Core var current = RouteDescriptorCollection.DefaultCollection.RemoveRouteDescriptor(destination, inputPortKey); if (current != null) { - Debug.LogMessage(LogEventLevel.Information, "Releasing current route: {0}", destination, current.Source.Key); + Debug.LogInformation(destination, "Releasing current route: {0}", current.Source.Key); current.ReleaseRoutes(clearRoute); } } catch (Exception ex) { - Debug.LogMessage(ex, "Exception releasing route for '{destination}':'{inputPortKey}'", null, destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); + Debug.LogError("Exception releasing route for '{destination}':'{inputPortKey}': {exception}", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey, ex.Message); + Debug.LogDebug(ex, "Stack Trace: "); } } @@ -321,13 +553,13 @@ namespace PepperDash.Essentials.Core /// /// /// - /// The RoutingOutputPort whose link is being checked for a route + /// The RoutingOutputPort whose link is being checked for a route /// Prevents Devices from being twice-checked /// This recursive function should not be called with AudioVideo /// Just an informational counter /// The RouteDescriptor being populated as the route is discovered - /// The RoutingOutputPort to use for the route - /// The specific source output port to use (optional) + /// The RoutingOutputPort whose link is being checked for a route + /// The source output port (optional) /// true if source is hit private static bool GetRouteToSource(this IRoutingInputs destination, IRoutingOutputs source, RoutingOutputPort outputPortToUse, List alreadyCheckedDevices, @@ -335,42 +567,46 @@ namespace PepperDash.Essentials.Core { cycle++; - Debug.LogMessage(LogEventLevel.Verbose, "GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString()); + Debug.LogVerbose("GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString()); RoutingInputPort goodInputPort = null; + // Use indexed lookup instead of LINQ query + var allDestinationTieLines = GetTieLinesForDestination(destination.Key); + IEnumerable destinationTieLines; TieLine directTie = null; if (destinationPort == null) { - destinationTieLines = TieLineCollection.Default.Where(t => - t.DestinationPort.ParentDevice.Key == destination.Key && (t.Type.HasFlag(signalType) || signalType == eRoutingSignalType.AudioVideo)); + destinationTieLines = allDestinationTieLines.Where(t => + t.Type.HasFlag(signalType) || signalType == eRoutingSignalType.AudioVideo); } else { - destinationTieLines = TieLineCollection.Default.Where(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.DestinationPort.Key == destinationPort.Key && (t.Type.HasFlag(signalType))); + destinationTieLines = allDestinationTieLines.Where(t => + t.DestinationPort.Key == destinationPort.Key && t.Type.HasFlag(signalType)); } // find the TieLine without a port if (destinationPort == null && sourcePort == null) { - directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.SourcePort.ParentDevice.Key == source.Key); + directTie = destinationTieLines.FirstOrDefault(t => t.SourcePort.ParentDevice.Key == source.Key); } // find a tieLine to a specific destination port without a specific source port else if (destinationPort != null && sourcePort == null) { - directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key); + directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key); } // find a tieline to a specific source port without a specific destination port else if (destinationPort == null & sourcePort != null) { - directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key); + directTie = destinationTieLines.FirstOrDefault(t => t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key); } // find a tieline to a specific source port and destination port else if (destinationPort != null && sourcePort != null) { - directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.ParentDevice.Key == destination.Key && t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key); + directTie = destinationTieLines.FirstOrDefault(t => t.DestinationPort.Key == destinationPort.Key && t.SourcePort.ParentDevice.Key == source.Key && t.SourcePort.Key == sourcePort.Key); } if (directTie != null) // Found a tie directly to the source @@ -379,12 +615,16 @@ namespace PepperDash.Essentials.Core } else // no direct-connect. Walk back devices. { - Debug.LogMessage(LogEventLevel.Verbose, "is not directly connected to {sourceKey}. Walking down tie lines", destination, source.Key); + Debug.LogVerbose(destination, "is not directly connected to {sourceKey}. Walking down tie lines", source.Key); // No direct tie? Run back out on the inputs' attached devices... // Only the ones that are routing devices - var midpointTieLines = destinationTieLines.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs); + var midpointTieLines = destinationTieLines + .Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs) + .ToList(); + Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count, destination.Key); + //Create a list for tracking already checked devices to avoid loops, if it doesn't already exist from previous iteration if (alreadyCheckedDevices == null) alreadyCheckedDevices = new List(); @@ -397,13 +637,13 @@ namespace PepperDash.Essentials.Core // Check if this previous device has already been walked if (alreadyCheckedDevices.Contains(midpointDevice)) { - Debug.LogMessage(LogEventLevel.Verbose, "Skipping input {midpointDeviceKey} on {destinationKey}, this was already checked", destination, midpointDevice.Key, destination.Key); + Debug.LogVerbose(destination, "Skipping input {midpointDeviceKey} on {destinationKey}, this was already checked", midpointDevice.Key, destination.Key); continue; } var midpointOutputPort = tieLine.SourcePort; - Debug.LogMessage(LogEventLevel.Verbose, "Trying to find route on {midpointDeviceKey}", destination, midpointDevice.Key); + Debug.LogVerbose(destination, "Trying to find route on {midpointDeviceKey}", midpointDevice.Key); // haven't seen this device yet. Do it. Pass the output port to the next // level to enable switching on success @@ -412,9 +652,9 @@ namespace PepperDash.Essentials.Core if (upstreamRoutingSuccess) { - Debug.LogMessage(LogEventLevel.Verbose, "Upstream device route found", destination); - Debug.LogMessage(LogEventLevel.Verbose, "Route found on {midpointDeviceKey}", destination, midpointDevice.Key); - Debug.LogMessage(LogEventLevel.Verbose, "TieLine: SourcePort: {SourcePort} DestinationPort: {DestinationPort}", destination, tieLine.SourcePort, tieLine.DestinationPort); + Debug.LogVerbose(destination, "Upstream device route found"); + Debug.LogVerbose(destination, "Route found on {midpointDeviceKey}", midpointDevice.Key); + Debug.LogVerbose(destination, "TieLine: SourcePort: {SourcePort} DestinationPort: {DestinationPort}", tieLine.SourcePort, tieLine.DestinationPort); goodInputPort = tieLine.DestinationPort; break; // Stop looping the inputs in this cycle } @@ -424,7 +664,8 @@ namespace PepperDash.Essentials.Core if (goodInputPort == null) { - Debug.LogMessage(LogEventLevel.Verbose, "No route found to {0}", destination, source.Key); + Debug.LogVerbose(destination, "No route found to {0} from destination {1} for type {2}", source.Key, destination.Key, signalType); + return false; } @@ -440,7 +681,7 @@ namespace PepperDash.Essentials.Core routeTable.Routes.Add(new RouteSwitchDescriptor(outputPortToUse, goodInputPort)); } else // device is merely IRoutingInputOutputs - Debug.LogMessage(LogEventLevel.Verbose, "No routing. Passthrough device", destination); + Debug.LogVerbose(destination, "No routing. Passthrough device"); return true; } diff --git a/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs b/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs index aab82d38..e698ae0d 100644 --- a/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs +++ b/src/PepperDash.Essentials.Core/Routing/RouteDescriptor.cs @@ -20,22 +20,27 @@ namespace PepperDash.Essentials.Core public IRoutingInputs Destination { get; private set; } /// - /// Gets or sets the InputPort + /// The InputPort on the destination device for this route, if applicable. May be null if the route is not for a specific input port. /// public RoutingInputPort InputPort { get; private set; } /// - /// Gets or sets the Source + /// Gets the source device (sink or midpoint) for the route. /// public IRoutingOutputs Source { get; private set; } /// - /// Gets or sets the SignalType + /// Gets the OutputPort on the source device for this route, if applicable. May be null if the route is not for a specific output port. + /// + public RoutingOutputPort OutputPort { get; private set; } + + /// + /// Gets the signal type for this route. /// public eRoutingSignalType SignalType { get; private set; } /// - /// Gets or sets the Routes + /// Gets the collection of route switch descriptors for this route. /// public List Routes { get; private set; } @@ -56,11 +61,24 @@ namespace PepperDash.Essentials.Core /// The destination device. /// The destination input port (optional). /// The signal type for this route. - public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, eRoutingSignalType signalType) + public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, eRoutingSignalType signalType) : this(source, destination, inputPort, null, signalType) + { + } + + /// + /// Initializes a new instance of the class for a route with specific destination input and source output ports. + /// + /// + /// + /// + /// + /// + public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, RoutingOutputPort outputPort, eRoutingSignalType signalType) { Destination = destination; InputPort = inputPort; Source = source; + OutputPort = outputPort; SignalType = signalType; Routes = new List(); } @@ -72,7 +90,7 @@ namespace PepperDash.Essentials.Core { foreach (var route in Routes) { - Debug.LogMessage(LogEventLevel.Verbose, "ExecuteRoutes: {0}", null, route.ToString()); + Debug.LogVerbose("ExecuteRoutes: {0}", route.ToString()); if (route.SwitchingDevice is IRoutingSinkWithSwitching sink) { @@ -86,7 +104,7 @@ namespace PepperDash.Essentials.Core route.OutputPort.InUseTracker.AddUser(Destination, "destination-" + SignalType); - Debug.LogMessage(LogEventLevel.Verbose, "Output port {0} routing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); + Debug.LogVerbose("Output port {0} routing. Count={1}", route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); } } } @@ -95,15 +113,15 @@ namespace PepperDash.Essentials.Core /// Releases the usage tracking for the route and optionally clears the route on the switching devices. /// /// If true, attempts to clear the route on the switching devices (e.g., set input to null/0). - - + + public void ReleaseRoutes(bool clearRoute = false) { foreach (var route in Routes.Where(r => r.SwitchingDevice is IRouting)) { if (route.SwitchingDevice is IRouting switchingDevice) { - if(clearRoute) + if (clearRoute) { try { @@ -112,6 +130,7 @@ namespace PepperDash.Essentials.Core catch (Exception e) { Debug.LogError("Error executing switch: {exception}", e.Message); + Debug.LogDebug(e, "Stack Trace: "); } } @@ -123,11 +142,11 @@ namespace PepperDash.Essentials.Core if (route.OutputPort.InUseTracker != null) { route.OutputPort.InUseTracker.RemoveUser(Destination, "destination-" + SignalType); - Debug.LogMessage(LogEventLevel.Verbose, "Port {0} releasing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); + Debug.LogVerbose("Port {0} releasing. Count={1}", route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); } else { - Debug.LogMessage(LogEventLevel.Error, "InUseTracker is null for OutputPort {0}", null, route.OutputPort.Key); + Debug.LogVerbose("InUseTracker is null for OutputPort {0}", route.OutputPort.Key); } } } @@ -137,98 +156,11 @@ namespace PepperDash.Essentials.Core /// Returns a string representation of the route descriptor, including source, destination, and individual route steps. /// /// A string describing the route. - - - public override string ToString() { var routesText = Routes.Select(r => r.ToString()).ToArray(); - return string.Format("Route table from {0} to {1}:\r{2}", Source.Key, Destination.Key, string.Join("\r", routesText)); + return $"Route table from {Source.Key} to {Destination.Key} for {SignalType}:\r\n {string.Join("\r\n ", routesText)}"; } } - /*/// - /// Represents an collection of individual route steps between Source and Destination - /// - /// - /// Represents a RouteDescriptor - /// - public class RouteDescriptor - { - /// - /// Gets or sets the Destination - /// - public IRoutingInputs Destination { get; private set; } - /// - /// Gets or sets the Source - /// - public IRoutingOutputs Source { get; private set; } - /// - /// Gets or sets the SignalType - /// - public eRoutingSignalType SignalType { get; private set; } - public List> Routes { get; private set; } - - - public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, eRoutingSignalType signalType) - { - Destination = destination; - Source = source; - SignalType = signalType; - Routes = new List>(); - } - - /// - /// ExecuteRoutes method - /// - public void ExecuteRoutes() - { - foreach (var route in Routes) - { - Debug.LogMessage(LogEventLevel.Verbose, "ExecuteRoutes: {0}", null, route.ToString()); - - if (route.SwitchingDevice is IRoutingSinkWithSwitching sink) - { - sink.ExecuteSwitch(route.InputPort.Selector); - continue; - } - - if (route.SwitchingDevice is IRouting switchingDevice) - { - switchingDevice.ExecuteSwitch(route.InputPort.Selector, route.OutputPort.Selector, SignalType); - - route.OutputPort.InUseTracker.AddUser(Destination, "destination-" + SignalType); - - Debug.LogMessage(LogEventLevel.Verbose, "Output port {0} routing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); - } - } - } - - /// - /// ReleaseRoutes method - /// - public void ReleaseRoutes() - { - foreach (var route in Routes) - { - if (route.SwitchingDevice is IRouting) - { - // Pull the route from the port. Whatever is watching the output's in use tracker is - // responsible for responding appropriately. - route.OutputPort.InUseTracker.RemoveUser(Destination, "destination-" + SignalType); - Debug.LogMessage(LogEventLevel.Verbose, "Port {0} releasing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); - } - } - } - - /// - /// ToString method - /// - /// - public override string ToString() - { - var routesText = Routes.Select(r => r.ToString()).ToArray(); - return string.Format("Route table from {0} to {1}:\r{2}", Source.Key, Destination.Key, string.Join("\r", routesText)); - } - }*/ } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs b/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs index 483ea22a..615b24c3 100644 --- a/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs +++ b/src/PepperDash.Essentials.Core/Routing/RouteDescriptorCollection.cs @@ -1,7 +1,7 @@ -using PepperDash.Core; -using Serilog.Events; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using PepperDash.Core; +using Serilog.Events; namespace PepperDash.Essentials.Core @@ -12,7 +12,7 @@ namespace PepperDash.Essentials.Core public class RouteDescriptorCollection { /// - /// DefaultCollection static property + /// Gets the default collection of RouteDescriptors. /// public static RouteDescriptorCollection DefaultCollection { @@ -27,6 +27,11 @@ namespace PepperDash.Essentials.Core private readonly List RouteDescriptors = new List(); + /// + /// Gets an enumerable collection of all RouteDescriptors in this collection. + /// + public IEnumerable Descriptors => RouteDescriptors.AsReadOnly(); + /// /// Adds a RouteDescriptor to the list. If an existing RouteDescriptor for the /// destination exists already, it will not be added - in order to preserve @@ -40,13 +45,34 @@ namespace PepperDash.Essentials.Core return; } - if (RouteDescriptors.Any(t => t.Destination == descriptor.Destination) - && RouteDescriptors.Any(t => t.Destination == descriptor.Destination && t.InputPort != null && descriptor.InputPort != null && t.InputPort.Key == descriptor.InputPort.Key)) + // Check if a route already exists with the same source, destination, input port, AND signal type + var existingRoute = RouteDescriptors.FirstOrDefault(t => + t.Source == descriptor.Source && + t.Destination == descriptor.Destination && + t.SignalType == descriptor.SignalType && + ((t.InputPort == null && descriptor.InputPort == null) || + (t.InputPort != null && descriptor.InputPort != null && t.InputPort.Key == descriptor.InputPort.Key)) && + ((t.OutputPort == null && descriptor.OutputPort == null) || + (t.OutputPort != null && descriptor.OutputPort != null && t.OutputPort.Key == descriptor.OutputPort.Key))); + + if (existingRoute != null) { - Debug.LogMessage(LogEventLevel.Debug, descriptor.Destination, - "Route to [{0}] already exists in global routes table", descriptor?.Source?.Key); + Debug.LogInformation(descriptor.Destination, + "Route from {source}:{outputPort} to {destination}:{inputPort} ({signalType}) already exists in this collection", + descriptor?.Source?.Key, + descriptor?.OutputPort?.Key ?? "auto", + descriptor?.Destination?.Key, + descriptor?.InputPort?.Key ?? "auto", + descriptor?.SignalType + ); return; } + Debug.LogVerbose("Adding route descriptor: {source}:{outputPort} -> {destination}:{inputPort} ({signalType})", + descriptor?.Source?.Key, + descriptor?.OutputPort?.Key ?? "auto", + descriptor?.Destination?.Key, + descriptor?.InputPort?.Key ?? "auto", + descriptor?.SignalType); RouteDescriptors.Add(descriptor); } @@ -61,11 +87,11 @@ namespace PepperDash.Essentials.Core } /// - /// Gets the RouteDescriptor for a destination and input port key. Returns null if no matching RouteDescriptor exists. + /// Gets the route descriptor for a specific destination and input port /// - /// - /// - /// + /// The destination device + /// The input port key + /// The matching RouteDescriptor or null if not found public RouteDescriptor GetRouteDescriptorForDestinationAndInputPort(IRoutingInputs destination, string inputPortKey) { Debug.LogMessage(LogEventLevel.Information, "Getting route descriptor for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); @@ -82,7 +108,7 @@ namespace PepperDash.Essentials.Core { Debug.LogMessage(LogEventLevel.Information, "Removing route descriptor for '{destination}':'{inputPortKey}'", destination.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); - var descr = string.IsNullOrEmpty(inputPortKey) + var descr = string.IsNullOrEmpty(inputPortKey) ? GetRouteDescriptorForDestination(destination) : GetRouteDescriptorForDestinationAndInputPort(destination, inputPortKey); if (descr != null) @@ -93,70 +119,4 @@ namespace PepperDash.Essentials.Core return descr; } } - - /*/// - /// A collection of RouteDescriptors - typically the static DefaultCollection is used - /// - /// - /// Represents a RouteDescriptorCollection - /// - public class RouteDescriptorCollection - { - public static RouteDescriptorCollection DefaultCollection - { - get - { - if (_DefaultCollection == null) - _DefaultCollection = new RouteDescriptorCollection(); - return _DefaultCollection; - } - } - private static RouteDescriptorCollection _DefaultCollection; - - private readonly List RouteDescriptors = new List(); - - /// - /// Adds a RouteDescriptor to the list. If an existing RouteDescriptor for the - /// destination exists already, it will not be added - in order to preserve - /// proper route releasing. - /// - /// - /// - /// AddRouteDescriptor method - /// - public void AddRouteDescriptor(RouteDescriptor descriptor) - { - if (RouteDescriptors.Any(t => t.Destination == descriptor.Destination)) - { - Debug.LogMessage(LogEventLevel.Debug, descriptor.Destination, - "Route to [{0}] already exists in global routes table", descriptor.Source.Key); - return; - } - RouteDescriptors.Add(descriptor); - } - - /// - /// Gets the RouteDescriptor for a destination - /// - /// null if no RouteDescriptor for a destination exists - /// - /// GetRouteDescriptorForDestination method - /// - public RouteDescriptor GetRouteDescriptorForDestination(IRoutingInputs destination) - { - return RouteDescriptors.FirstOrDefault(rd => rd.Destination == destination); - } - - /// - /// Returns the RouteDescriptor for a given destination AND removes it from collection. - /// Returns null if no route with the provided destination exists. - /// - public RouteDescriptor RemoveRouteDescriptor(IRoutingInputs destination) - { - var descr = GetRouteDescriptorForDestination(destination); - if (descr != null) - RouteDescriptors.Remove(descr); - return descr; - } - }*/ } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs b/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs index 12aebdcf..227e6d5b 100644 --- a/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs +++ b/src/PepperDash.Essentials.Core/Routing/RouteSwitchDescriptor.cs @@ -4,96 +4,51 @@ /// Represents a RouteSwitchDescriptor /// public class RouteSwitchDescriptor - { - /// - /// Gets or sets the SwitchingDevice - /// - public IRoutingInputs SwitchingDevice { get { return InputPort?.ParentDevice; } } - /// - /// The output port being switched from (relevant for matrix switchers). Null for sink devices. - /// - public RoutingOutputPort OutputPort { get; set; } - /// - /// The input port being switched to. - /// - public RoutingInputPort InputPort { get; set; } - - /// - /// Initializes a new instance of the class for sink devices (no output port). - /// - /// The input port being switched to. - public RouteSwitchDescriptor(RoutingInputPort inputPort) - { - InputPort = inputPort; - } - - /// - /// Initializes a new instance of the class for matrix switchers. - /// - /// The output port being switched from. - /// The input port being switched to. - public RouteSwitchDescriptor(RoutingOutputPort outputPort, RoutingInputPort inputPort) - { - InputPort = inputPort; - OutputPort = outputPort; - } - - /// - /// Returns a string representation of the route switch descriptor. - /// - /// A string describing the switch operation. - /// - public override string ToString() - { - if (SwitchingDevice is IRouting) - return $"{(SwitchingDevice != null ? SwitchingDevice.Key : "No Device")} switches output {(OutputPort != null ? OutputPort.Key : "No output port")} to input {(InputPort != null ? InputPort.Key : "No input port")}"; - else - return $"{(SwitchingDevice != null ? SwitchingDevice.Key : "No Device")} switches to input {(InputPort != null ? InputPort.Key : "No input port")}"; - } - } - - /*/// - /// Represents an individual link for a route - /// - /// - /// Represents a RouteSwitchDescriptor - /// - public class RouteSwitchDescriptor { /// /// Gets or sets the SwitchingDevice /// - public IRoutingInputs SwitchingDevice { get { return InputPort.ParentDevice; } } + public IRoutingInputs SwitchingDevice { get { return InputPort?.ParentDevice; } } /// - /// Gets or sets the OutputPort + /// The output port being switched from (relevant for matrix switchers). Null for sink devices. /// - public RoutingOutputPort OutputPort { get; set; } + public RoutingOutputPort OutputPort { get; set; } /// - /// Gets or sets the InputPort + /// The input port being switched to. /// - public RoutingInputPort InputPort { get; set; } + public RoutingInputPort InputPort { get; set; } - public RouteSwitchDescriptor(RoutingInputPort inputPort) + /// + /// Initializes a new instance of the class for sink devices (no output port). + /// + /// The input port being switched to. + public RouteSwitchDescriptor(RoutingInputPort inputPort) { InputPort = inputPort; } - public RouteSwitchDescriptor(RoutingOutputPort outputPort, RoutingInputPort inputPort) + /// + /// Initializes a new instance of the class for matrix switchers. + /// + /// The output port being switched from. + /// The input port being switched to. + public RouteSwitchDescriptor(RoutingOutputPort outputPort, RoutingInputPort inputPort) { InputPort = inputPort; OutputPort = outputPort; } /// - /// ToString method + /// Returns a string representation of the route switch descriptor. /// + /// A string describing the switch operation. /// public override string ToString() { if (SwitchingDevice is IRouting) - return string.Format("{0} switches output '{1}' to input '{2}'", SwitchingDevice.Key, OutputPort.Selector, InputPort.Selector); + return $"{(SwitchingDevice != null ? SwitchingDevice.Key : "No Device")} switches output {(OutputPort != null ? OutputPort.Key : "No output port")} to input {(InputPort != null ? InputPort.Key : "No input port")}"; else - return string.Format("{0} switches to input '{1}'", SwitchingDevice.Key, InputPort.Selector); + return $"{(SwitchingDevice != null ? SwitchingDevice.Key : "No Device")} switches to input {(InputPort != null ? InputPort.Key : "No input port")}"; } - }*/ + } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs index e37d2d57..7fe41c5c 100644 --- a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs +++ b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Timers; using PepperDash.Core; using PepperDash.Essentials.Core.Config; @@ -11,6 +13,26 @@ namespace PepperDash.Essentials.Core.Routing /// public class RoutingFeedbackManager : EssentialsDevice { + /// + /// Maps midpoint device keys to the set of sink device keys that are downstream + /// + private Dictionary> midpointToSinksMap; + + /// + /// Debounce timers for each sink device to prevent rapid successive updates + /// + private readonly Dictionary updateTimers = new Dictionary(); + + /// + /// Lock object protecting all access to . + /// + private readonly object _timerLock = new object(); + + /// + /// Debounce delay in milliseconds + /// + private const long DEBOUNCE_MS = 500; + /// /// Initializes a new instance of the class. /// @@ -19,10 +41,103 @@ namespace PepperDash.Essentials.Core.Routing public RoutingFeedbackManager(string key, string name) : base(key, name) { + AddPreActivationAction(BuildMidpointSinkMap); AddPreActivationAction(SubscribeForMidpointFeedback); AddPreActivationAction(SubscribeForSinkFeedback); } + /// + /// Builds a map of which sink devices are downstream of each midpoint device + /// for performance optimization in HandleMidpointUpdate + /// + private void BuildMidpointSinkMap() + { + midpointToSinksMap = new Dictionary>(); + + var sinks = DeviceManager.AllDevices.OfType(); + + foreach (var sink in sinks) + { + if (sink.CurrentInputPort == null) + continue; + + // Find all upstream midpoints for this sink + var upstreamMidpoints = GetUpstreamMidpoints(sink); + + foreach (var midpointKey in upstreamMidpoints) + { + if (string.IsNullOrEmpty(midpointKey)) + continue; + + if (!midpointToSinksMap.ContainsKey(midpointKey)) + midpointToSinksMap[midpointKey] = new HashSet(); + + midpointToSinksMap[midpointKey].Add(sink.Key); + } + } + + Debug.LogMessage( + Serilog.Events.LogEventLevel.Information, + "Built midpoint-to-sink map with {count} midpoints", + this, + midpointToSinksMap.Count + ); + } + + /// + /// Gets all upstream midpoint device keys for a given sink + /// + private HashSet GetUpstreamMidpoints(IRoutingSinkWithSwitchingWithInputPort sink) + { + var result = new HashSet(); + var visited = new HashSet(); + + if (sink.CurrentInputPort == null) + return result; + + var tieLine = TieLineCollection.Default.FirstOrDefault(tl => + tl.DestinationPort.Key == sink.CurrentInputPort.Key && + tl.DestinationPort.ParentDevice.Key == sink.CurrentInputPort.ParentDevice.Key); + + if (tieLine == null) + return result; + + TraceUpstreamMidpoints(tieLine, result, visited); + return result; + } + + /// + /// Recursively traces upstream to find all midpoint devices + /// + private void TraceUpstreamMidpoints(TieLine tieLine, HashSet midpoints, HashSet visited) + { + if (tieLine == null || visited.Contains(tieLine.SourcePort.ParentDevice.Key)) + return; + + visited.Add(tieLine.SourcePort.ParentDevice.Key); + + if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint) + { + if (!string.IsNullOrEmpty(midpoint.Key)) + midpoints.Add(midpoint.Key); + + // Find upstream TieLines connected to this midpoint's inputs + var midpointInputs = (midpoint as IRoutingInputs)?.InputPorts; + if (midpointInputs != null) + { + foreach (var inputPort in midpointInputs) + { + var upstreamTieLine = TieLineCollection.Default.FirstOrDefault(tl => + tl.DestinationPort.Key == inputPort.Key && + tl.DestinationPort.ParentDevice.Key == inputPort.ParentDevice.Key); + + if (upstreamTieLine != null) + TraceUpstreamMidpoints(upstreamTieLine, midpoints, visited); + } + } + } + } + /// /// Subscribes to the RouteChanged event on all devices implementing . /// @@ -52,7 +167,7 @@ namespace PepperDash.Essentials.Core.Routing /// /// Handles the RouteChanged event from a midpoint device. - /// Triggers an update for all sink devices. + /// Only triggers updates for sink devices that are downstream of this midpoint. /// /// The midpoint device that reported a route change. /// The descriptor of the new route. @@ -63,12 +178,33 @@ namespace PepperDash.Essentials.Core.Routing { try { - var devices = - DeviceManager.AllDevices.OfType(); - - foreach (var device in devices) + // Only update affected sinks (performance optimization) + if (midpointToSinksMap != null && midpointToSinksMap.TryGetValue(midpoint.Key, out var affectedSinkKeys)) { - UpdateDestination(device, device.CurrentInputPort); + Debug.LogMessage( + Serilog.Events.LogEventLevel.Debug, + "Midpoint {midpoint} changed, updating {count} downstream sinks", + this, + midpoint.Key, + affectedSinkKeys.Count + ); + + foreach (var sinkKey in affectedSinkKeys) + { + if (DeviceManager.GetDeviceForKey(sinkKey) is IRoutingSinkWithSwitchingWithInputPort sink) + { + UpdateDestination(sink, sink.CurrentInputPort); + } + } + } + else + { + Debug.LogMessage( + Serilog.Events.LogEventLevel.Debug, + "Midpoint {midpoint} changed but has no downstream sinks in map", + this, + midpoint.Key + ); } } catch (Exception ex) @@ -83,9 +219,49 @@ namespace PepperDash.Essentials.Core.Routing } } + /// + /// Removes a sink from every midpoint set in the map and re-adds it based on its + /// current input port. Call this whenever a sink's selected input changes so that + /// HandleMidpointUpdate always sees an up-to-date downstream set. + /// + private void RebuildMapForSink(IRoutingSinkWithSwitchingWithInputPort sink) + { + if (midpointToSinksMap == null) + return; + + // Remove this sink from all existing midpoint sets + foreach (var set in midpointToSinksMap.Values) + set.Remove(sink.Key); + + // Drop any midpoint entries that are now empty + var emptyKeys = midpointToSinksMap + .Where(kvp => kvp.Value.Count == 0) + .Select(kvp => kvp.Key) + .ToList(); + foreach (var k in emptyKeys) + midpointToSinksMap.Remove(k); + + // Re-add the sink under every midpoint that is upstream of its new input + if (sink.CurrentInputPort == null) + return; + + var upstreamMidpoints = GetUpstreamMidpoints(sink); + foreach (var midpointKey in upstreamMidpoints) + { + if (string.IsNullOrEmpty(midpointKey)) + continue; + + if (!midpointToSinksMap.ContainsKey(midpointKey)) + midpointToSinksMap[midpointKey] = new HashSet(); + + midpointToSinksMap[midpointKey].Add(sink.Key); + } + } + /// /// Handles the InputChanged event from a sink device. - /// Triggers an update for the specific sink device. + /// Updates the midpoint-to-sink map for the new input path, then triggers + /// a source-info update for the sink. /// /// The sink device that reported an input change. /// The new input port selected on the sink device. @@ -96,6 +272,10 @@ namespace PepperDash.Essentials.Core.Routing { try { + // Keep the map current so HandleMidpointUpdate can find this sink + if (sender is IRoutingSinkWithSwitchingWithInputPort sinkWithInputPort) + RebuildMapForSink(sinkWithInputPort); + UpdateDestination(sender, currentInputPort); } catch (Exception ex) @@ -113,6 +293,7 @@ namespace PepperDash.Essentials.Core.Routing /// /// Updates the CurrentSourceInfo and CurrentSourceInfoKey properties on a destination (sink) device /// based on its currently selected input port by tracing the route back through tie lines. + /// Uses debouncing to prevent rapid successive updates. /// /// The destination sink device to update. /// The currently selected input port on the destination device. @@ -120,6 +301,76 @@ namespace PepperDash.Essentials.Core.Routing IRoutingSinkWithSwitching destination, RoutingInputPort inputPort ) + { + if (destination == null) + return; + + var key = destination.Key; + + // Cancel and replace any existing timer under the lock so no callback + // can race with us while we swap the entry. + Timer timerToDispose = null; + Timer newTimer = null; + + newTimer = new Timer(DEBOUNCE_MS) { AutoReset = false }; + newTimer.Elapsed += (s, e) => + { + try + { + UpdateDestinationImmediate(destination, inputPort); + } + catch (Exception ex) + { + Debug.LogMessage( + ex, + "Error in debounced update for destination {destinationKey}: {message}", + this, + destination.Key, + ex.Message + ); + } + finally + { + // Remove the entry first so a concurrent UpdateDestination call + // cannot re-dispose whatever timer we're about to dispose. + Timer selfTimer = null; + lock (_timerLock) + { + if (updateTimers.TryGetValue(key, out var current) && ReferenceEquals(current, newTimer)) + { + selfTimer = current; + updateTimers.Remove(key); + } + } + selfTimer?.Dispose(); + } + }; + + lock (_timerLock) + { + if (updateTimers.TryGetValue(key, out var existingTimer)) + timerToDispose = existingTimer; + + updateTimers[key] = newTimer; + } + + // Dispose the old timer outside the lock to avoid holding the lock during disposal. + // Dispose implicitly stops the timer, preventing its Elapsed event from firing. + timerToDispose?.Dispose(); + + // Start after the lock is released so the Elapsed callback cannot deadlock + // trying to acquire _timerLock while we still hold it. + newTimer.Start(); + } + + /// + /// Immediately updates the CurrentSourceInfo for a destination device. + /// Called after debounce delay. + /// + private void UpdateDestinationImmediate( + IRoutingSinkWithSwitching destination, + RoutingInputPort inputPort + ) { Debug.LogMessage( Serilog.Events.LogEventLevel.Debug, @@ -206,7 +457,8 @@ namespace PepperDash.Essentials.Core.Routing } catch (Exception ex) { - Debug.LogMessage(ex, "Error getting sourceTieLine: {Exception}", this, ex); + Debug.LogError(this, "Error getting sourceTieLine: {message}", ex.Message); + Debug.LogDebug(ex, "StackTrace: "); return; } @@ -230,6 +482,11 @@ namespace PepperDash.Essentials.Core.Routing return roomDefaultDisplay.DefaultDisplay.Key == destination.Key; } + if (ConfigReader.ConfigObject.GetDestinationListForKey(r.DestinationListKey)?.FirstOrDefault(d => d.Value.SinkKey == destination.Key) != null) + { + return true; + } + return false; } ); @@ -251,10 +508,8 @@ namespace PepperDash.Essentials.Core.Routing if (sourceList == null) { - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, + Debug.LogDebug(this, "No source list found for source list key {key}. Unable to find source for tieLine {sourceTieLine}", - this, room.SourceListKey, sourceTieLine ); @@ -283,10 +538,8 @@ namespace PepperDash.Essentials.Core.Routing if (source == null) { - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, + Debug.LogDebug(this, "No source found for device {key}. Creating transient source for {destination}", - this, sourceTieLine.SourcePort.ParentDevice.Key, destination ); @@ -309,105 +562,92 @@ namespace PepperDash.Essentials.Core.Routing } /// - /// Recursively traces a route back from a given tie line to find the root source tie line. - /// It navigates through midpoint devices () by checking their current routes. + /// Traces a route back from a given tie line to find the root source tie line. + /// Leverages the existing Extensions.GetRouteToSource method with loop protection. /// /// The starting tie line (typically connected to a sink or midpoint). /// The connected to the original source device, or null if the source cannot be determined. private TieLine GetRootTieLine(TieLine tieLine) { - TieLine nextTieLine = null; try { - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "**Following tieLine {tieLine}**", this, tieLine); - - if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint) + if (!(tieLine.DestinationPort.ParentDevice is IRoutingInputs sink)) { - // Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "TieLine Source device {sourceDevice} is midpoint", this, midpoint); + Debug.LogDebug(this, + "TieLine destination {device} is not IRoutingInputs", + tieLine.DestinationPort.ParentDevice.Key + ); + return null; + } - if (midpoint.CurrentRoutes == null || midpoint.CurrentRoutes.Count == 0) + // Get all potential sources (devices that only have outputs, not inputs+outputs) + var sources = DeviceManager.AllDevices + .OfType() + .Where(s => !(s is IRoutingInputsOutputs)); + + // Try each signal type that this TieLine supports + var signalTypes = new[] + { + eRoutingSignalType.Audio, + eRoutingSignalType.Video, + eRoutingSignalType.AudioVideo, + eRoutingSignalType.SecondaryAudio, + eRoutingSignalType.UsbInput, + eRoutingSignalType.UsbOutput + }; + + foreach (var signalType in signalTypes) + { + if (!tieLine.Type.HasFlag(signalType)) + continue; + + foreach (var source in sources) { - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, - "Midpoint {midpointKey} has no routes", - this, - midpoint.Key + // Use the optimized route discovery with loop protection + var (route, _) = sink.GetRouteToSource( + source, + signalType, + tieLine.DestinationPort, + null ); - return null; + + if (route != null && route.Routes != null && route.Routes.Count > 0) + { + // Routes[0] is the hop nearest the source: its InputPort is the + // port on the first switching device that receives the signal from + // the source side. The TieLine whose DestinationPort matches that + // port is the exact tie that was traversed, giving us the precise + // source output port via SourcePort — regardless of how many output + // ports the source device has. + var firstHop = route.Routes[0]; + var sourceTieLine = TieLineCollection.Default.FirstOrDefault(tl => + tl.DestinationPort.Key == firstHop.InputPort.Key && + tl.DestinationPort.ParentDevice.Key == firstHop.InputPort.ParentDevice.Key); + + if (sourceTieLine != null) + { + Debug.LogDebug(this, + "Found route from {source} to {sink} with {count} hops", + source.Key, + sink.Key, + route.Routes.Count + ); + return sourceTieLine; + } + } } - - var currentRoute = midpoint.CurrentRoutes.FirstOrDefault(route => - { - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Checking {route} against {tieLine}", this, route, tieLine); - - return route.OutputPort != null - && route.InputPort != null - && route.OutputPort?.Key == tieLine.SourcePort.Key - && route.OutputPort?.ParentDevice.Key - == tieLine.SourcePort.ParentDevice.Key; - }); - - if (currentRoute == null) - { - Debug.LogMessage( - Serilog.Events.LogEventLevel.Debug, - "No route through midpoint {midpoint} for outputPort {outputPort}", - this, - midpoint.Key, - tieLine.SourcePort - ); - return null; - } - - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found currentRoute {currentRoute} through {midpoint}", this, currentRoute, midpoint); - - nextTieLine = TieLineCollection.Default.FirstOrDefault(tl => - { - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Checking {route} against {tieLine}", tl.DestinationPort.Key, currentRoute.InputPort.Key); - return tl.DestinationPort.Key == currentRoute.InputPort.Key - && tl.DestinationPort.ParentDevice.Key - == currentRoute.InputPort.ParentDevice.Key; - }); - - if (nextTieLine != null) - { - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found next tieLine {tieLine}. Walking the chain", this, nextTieLine); - return GetRootTieLine(nextTieLine); - } - - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found root tieLine {tieLine}", this,nextTieLine); - return nextTieLine; } - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "TieLIne Source Device {sourceDeviceKey} is IRoutingSource: {isIRoutingSource}", this, tieLine.SourcePort.ParentDevice.Key, tieLine.SourcePort.ParentDevice is IRoutingSource); - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "TieLine Source Device interfaces: {typeFullName}:{interfaces}", this, tieLine.SourcePort.ParentDevice.GetType().FullName, tieLine.SourcePort.ParentDevice.GetType().GetInterfaces().Select(i => i.Name)); - - if ( - tieLine.SourcePort.ParentDevice is IRoutingSource - || tieLine.SourcePort.ParentDevice is IRoutingOutputs - ) //end of the chain - { - // Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, "Found root: {tieLine}", this, tieLine); - return tieLine; - } - - nextTieLine = TieLineCollection.Default.FirstOrDefault(tl => - tl.DestinationPort.Key == tieLine.SourcePort.Key - && tl.DestinationPort.ParentDevice.Key == tieLine.SourcePort.ParentDevice.Key - ); - - if (nextTieLine != null) - { - return GetRootTieLine(nextTieLine); - } + Debug.LogDebug(this, "No route found to any source from {sink}", sink.Key); + return null; } catch (Exception ex) { - Debug.LogMessage(ex, "Error walking tieLines: {Exception}", this, ex); + Debug.LogError(this, "Error getting root tieLine: {message}", ex.Message); + Debug.LogDebug(ex, "StackTrace: "); + return null; } - - return null; } } } diff --git a/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs b/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs index 9fce9c5b..708b7448 100644 --- a/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs +++ b/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs @@ -27,16 +27,19 @@ namespace PepperDash.Essentials.Core /// /// Control signal type /// + [Obsolete("UsbOutput is no longer supported and will be removed in a future release.")] UsbOutput = 8, /// /// Control signal type /// + [Obsolete("UsbInput is no longer supported and will be removed in a future release.")] UsbInput = 16, /// /// Secondary audio signal type /// + [Obsolete("SecondaryAudio is no longer supported and will be removed in a future release.")] SecondaryAudio = 32 } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs index 382f5d55..ac4e80d8 100644 --- a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs +++ b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using Crestron.SimplSharp; using PepperDash.Core; +using PepperDash.Core.Logging; using Serilog.Events; namespace PepperDash.Essentials.Core @@ -296,19 +297,14 @@ namespace PepperDash.Essentials.Core { var secretPresent = provider.TestSecret(key); - Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); + provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); if (!secretPresent) return - String.Format( - "Unable to update secret for {0}:{1} - Please use the 'SetSecret' command to modify it"); + $"Unable to update secret for {provider.Key}:{key} - Please use the 'SetSecret' command to modify it"; var response = provider.SetSecret(key, secret) - ? String.Format( - "Secret successfully set for {0}:{1}", - provider.Key, key) - : String.Format( - "Unable to set secret for {0}:{1}", - provider.Key, key); + ? $"Secret successfully set for {provider.Key}:{key}" + : $"Unable to set secret for {provider.Key}:{key}"; return response; } @@ -316,19 +312,14 @@ namespace PepperDash.Essentials.Core { var secretPresent = provider.TestSecret(key); - Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); + provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); if (secretPresent) return - String.Format( - "Unable to set secret for {0}:{1} - Please use the 'UpdateSecret' command to modify it"); + $"Unable to set secret for {provider.Key}:{key} - Please use the 'UpdateSecret' command to modify it"; var response = provider.SetSecret(key, secret) - ? String.Format( - "Secret successfully set for {0}:{1}", - provider.Key, key) - : String.Format( - "Unable to set secret for {0}:{1}", - provider.Key, key); + ? $"Secret successfully set for {provider.Key}:{key}" + : $"Unable to set secret for {provider.Key}:{key}"; return response; } @@ -377,15 +368,10 @@ namespace PepperDash.Essentials.Core var key = args[1]; - provider.SetSecret(key, ""); response = provider.SetSecret(key, "") - ? String.Format( - "Secret successfully deleted for {0}:{1}", - provider.Key, key) - : String.Format( - "Unable to delete secret for {0}:{1}", - provider.Key, key); + ? $"Secret successfully deleted for {provider.Key}:{key}" + : $"Unable to delete secret for {provider.Key}:{key}"; CrestronConsole.ConsoleCommandResponse(response); return; diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index ffbe5509..cfbaa1df 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -10,78 +10,96 @@ using Serilog.Events; namespace PepperDash.Essentials.Core.Web { - /// - /// Represents a EssentialsWebApi - /// - public class EssentialsWebApi : EssentialsDevice - { - private readonly WebApiServer _server; + /// + /// Represents a EssentialsWebApi + /// + public class EssentialsWebApi : EssentialsDevice + { + private readonly WebApiServer _server; - /// - /// http(s)://{ipaddress}/cws/{basePath} - /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} - /// - private readonly string _defaultBasePath = CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance - ? string.Format("/app{0:00}/api", InitialParametersClass.ApplicationNumber) - : "/api"; + private readonly WebApiServer _debugServer; - private const int DebugTrace = 0; - private const int DebugInfo = 1; - private const int DebugVerbose = 2; - /// - /// Gets or sets the BasePath - /// - public string BasePath { get; private set; } - /// - /// Tracks if CWS is registered - /// - public bool IsRegistered - { - get { return _server.IsRegistered; } - } - /// - /// Constructor - /// - /// - /// - public EssentialsWebApi(string key, string name) - : this(key, name, null) - { - } + /// + /// http(s)://{ipaddress}/cws/{basePath} + /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} + /// + private readonly string _defaultBasePath = CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance + ? string.Format("/app{0:00}/api", InitialParametersClass.ApplicationNumber) + : "/api"; - /// - /// Constructor - /// - /// - /// - /// - public EssentialsWebApi(string key, string name, EssentialsWebApiPropertiesConfig config) - : base(key, name) - { - Key = key; + private const int DebugTrace = 0; + private const int DebugInfo = 1; + private const int DebugVerbose = 2; - if (config == null) - BasePath = _defaultBasePath; - else - BasePath = string.IsNullOrEmpty(config.BasePath) ? _defaultBasePath : config.BasePath; + /// + /// Gets or sets the BasePath + /// + public string BasePath { get; private set; } - _server = new WebApiServer(Key, Name, BasePath); + /// + /// Tracks if CWS is registered + /// + public bool IsRegistered + { + get { return _server.IsRegistered; } + } - SetupRoutes(); - } + /// + /// Constructor + /// + /// + /// + public EssentialsWebApi(string key, string name) + : this(key, name, null) + { + } - private void SetupRoutes() - { + /// + /// Constructor + /// + /// + /// + /// + public EssentialsWebApi(string key, string name, EssentialsWebApiPropertiesConfig config) + : base(key, name) + { + Key = key; + + if (config == null) + BasePath = _defaultBasePath; + else + BasePath = string.IsNullOrEmpty(config.BasePath) ? _defaultBasePath : config.BasePath; + + _server = new WebApiServer(Key, Name, BasePath); + + _debugServer = new WebApiServer($"{key}-debug-app", $"{name} Debug App", "/debug"); + _debugServer.SetFallbackHandler(new ServeDebugAppRequestHandler()); + + SetupRoutes(); + } + + private void SetupRoutes() + { var routes = new List { + new HttpCwsRoute("login") + { + Name = "Root", + RouteHandler = new LoginRequestHandler() + }, new HttpCwsRoute("versions") { Name = "ReportVersions", RouteHandler = new ReportVersionsRequestHandler() }, + new HttpCwsRoute("packageManifest") + { + Name = "GetPackageManifest", + RouteHandler = new GetPackageManifestRequestHandler() + }, new HttpCwsRoute("appdebug") { Name = "AppDebug", @@ -177,6 +195,11 @@ namespace PepperDash.Essentials.Core.Web Name = "Get Routing Ports for a device", RouteHandler = new GetRoutingPortsHandler() }, + new HttpCwsRoute("routingDevicesAndTieLines") + { + Name = "Get Routing Devices and TieLines", + RouteHandler = new GetRoutingDevicesAndTieLinesHandler() + }, }; AddRoute(routes); @@ -211,78 +234,89 @@ namespace PepperDash.Essentials.Core.Web /// /// public override void Initialize() - { - AddRoute(new HttpCwsRoute("apiPaths") { + { + AddRoute(new HttpCwsRoute("apiPaths") + { Name = "GetPaths", RouteHandler = new GetRoutesHandler(_server.GetRouteCollection(), BasePath) }); // If running on an appliance if (CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance) - { - /* + { + /* WEBSERVER [ON | OFF | TIMEOUT | MAXSESSIONSPERUSER ] */ - var response = string.Empty; - CrestronConsole.SendControlSystemCommand("webserver", ref response); - if (response.Contains("OFF")) return; + var response = string.Empty; + CrestronConsole.SendControlSystemCommand("webserver", ref response); + if (response.Contains("OFF")) return; - var is4Series = eCrestronSeries.Series4 == (Global.ProcessorSeries & eCrestronSeries.Series4); - Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series"); + var is4Series = eCrestronSeries.Series4 == (Global.ProcessorSeries & eCrestronSeries.Series4); + Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series"); - _server.Start(); + _server.Start(); + _debugServer.Start(); - GetPaths(); + GetPaths(); - return; - } + return; + } - // Automatically start CWS when running on a server (Linux OS, Virtual Control) - Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server"); + // Automatically start CWS when running on a server (Linux OS, Virtual Control) + Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server"); - _server.Start(); + _server.Start(); + _debugServer.Start(); - GetPaths(); - } + GetPaths(); + } - /// - /// Print the available pahts - /// - /// - /// http(s)://{ipaddress}/cws/{basePath} - /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} - /// - /// - /// GetPaths method - /// - public void GetPaths() - { - Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); + /// + /// Print the available paths + /// + /// + /// http(s)://{ipaddress}/cws/{basePath} + /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} + /// + /// + /// GetPaths method + /// + public void GetPaths() + { + Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); - var currentIp = CrestronEthernetHelper.GetEthernetParameter( - CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); - - var hostname = CrestronEthernetHelper.GetEthernetParameter( + var currentIp = CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); + + var hostname = CrestronEthernetHelper.GetEthernetParameter( CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0); var path = CrestronEnvironment.DevicePlatform == eDevicePlatform.Server ? $"https://{hostname}/VirtualControl/Rooms/{InitialParametersClass.RoomId}/cws{BasePath}" : $"https://{currentIp}/cws{BasePath}"; - - Debug.LogMessage(LogEventLevel.Information, this, "Server:{path:l}", path); - var routeCollection = _server.GetRouteCollection(); - if (routeCollection == null) - { - Debug.LogMessage(LogEventLevel.Information, this, "Server route collection is null"); - return; - } - Debug.LogMessage(LogEventLevel.Information, this, "Configured Routes:"); - foreach (var route in routeCollection) - { - Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url); - } - Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); - } - } + Debug.LogMessage(LogEventLevel.Information, this, "Server:{path:l}", path); + + var routeCollection = _server.GetRouteCollection(); + if (routeCollection == null) + { + Debug.LogMessage(LogEventLevel.Information, this, "Server route collection is null"); + return; + } + Debug.LogMessage(LogEventLevel.Information, this, "Configured Routes:"); + foreach (var route in routeCollection) + { + Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url); + } + Debug.LogInformation(this, "Web API initialized and ready to accept requests"); + + Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); + + var debugAppUrl = CrestronEnvironment.DevicePlatform == eDevicePlatform.Server + ? $"https://{hostname}/VirtualControl/Rooms/{InitialParametersClass.RoomId}/cws/debug" + : $"https://{currentIp}/cws/debug"; + + Debug.LogMessage(LogEventLevel.Information, this, "Developer Tools Web App available at: {debugAppUrl:l}", debugAppUrl); + } + } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs index 75f90189..c966ea59 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs @@ -36,17 +36,25 @@ namespace PepperDash.Essentials.Core.Web }; } + + /// /// MapToDeviceListObject method /// public static object MapToDeviceListObject(IKeyed device) { + var interfaces = device.GetType() + .GetInterfaces() + .Select(i => i.Name) + .ToList(); + return new { device.Key, Name = (device is IKeyName) ? (device as IKeyName).Name - : "---" + : "---", + Interfaces = interfaces }; } @@ -110,5 +118,6 @@ namespace PepperDash.Essentials.Core.Web CType = device.Value.Type == null ? "---": device.Value.Type.ToString() }; } - } + + } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index 01e38834..59c662dc 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -17,7 +17,10 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// Represents a DebugSessionRequestHandler /// public class DebugSessionRequestHandler : WebApiBaseRequestHandler - { + { + private CTimer _portForwardTimeoutTimer; + private readonly object _timerLock = new object(); + /// /// Constructor /// @@ -48,6 +51,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); var port = 0; + string csIp = null; if (!Debug.WebsocketSink.IsRunning) { @@ -59,14 +63,50 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers 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 adding port forwarding for debug websocket: {0}", 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); + } + 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); @@ -90,8 +130,49 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// protected override void HandlePost(HttpCwsContext context) { + CancelPortForwardTimeout(); + + var port = Debug.WebsocketSink.Port; + Debug.WebsocketSink.StopServer(); + // Remove the port forwarding entry + try + { + var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType( + EthernetAdapterType.EthernetCSAdapter); + var csIp = CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); + + if (port <= 0) + { + Debug.LogMessage(LogEventLevel.Debug, "Debug websocket port is not set; skipping port forwarding removal"); + } + else + { + 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 debug websocket: {0}", result); + } + else + { + Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed", port); + } + } + } + catch (ArgumentException) + { + Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding removal"); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Warning, "Error removing debug websocket port forwarding: {0}", ex.Message); + } + context.Response.StatusCode = 200; context.Response.StatusDescription = "OK"; context.Response.End(); @@ -99,5 +180,55 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers 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/GetFeedbacksForDeviceRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs index ca9eeb81..9831fa91 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs @@ -1,13 +1,14 @@ -using System.Linq; +using System; +using System.Linq; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core.Web.RequestHandlers; namespace PepperDash.Essentials.Core.Web.RequestHandlers { - /// - /// Represents a GetFeedbacksForDeviceRequestHandler - /// + /// + /// Represents a GetFeedbacksForDeviceRequestHandler + /// public class GetFeedbacksForDeviceRequestHandler : WebApiBaseRequestHandler { /// @@ -76,7 +77,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers Value = feedback.IntValue }; - var stringFeedback = + var stringFeedback = from feedback in device.Feedbacks.OfType() where !string.IsNullOrEmpty(feedback.Key) select new diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs new file mode 100644 index 00000000..eeea75cd --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -0,0 +1,259 @@ +using System; +using System.Linq; +using System.Reflection; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Config; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Represents a GetPackageManifestRequestHandler + /// + public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler + { + /// + /// Constructor + /// + /// + /// base(true) enables CORS support by default + /// + public GetPackageManifestRequestHandler() + : base(true) + { + } + + /// + /// Handles GET method requests + /// + /// + protected override void HandleGet(HttpCwsContext context) + { + try + { + var result = CloneVersionData(ConfigReader.ConfigObject?.Versions) ?? new VersionData(); + + PopulateEssentials(result); + PopulatePackages(result); + + var js = JsonConvert.SerializeObject(result, Formatting.Indented); + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(js, false); + context.Response.End(); + } + catch (Exception ex) + { + PepperDash.Core.Debug.LogMessage(ex, "Exception handling GET /packageManifest request"); + context.Response.StatusCode = 500; + context.Response.StatusDescription = "Internal Server Error"; + context.Response.End(); + } + } + + /// + /// Deep-copies the config's VersionData so the live config object is never mutated + /// + private static VersionData CloneVersionData(VersionData source) + { + if (source == null) + { + return null; + } + + var json = JsonConvert.SerializeObject(source); + return JsonConvert.DeserializeObject(json); + } + + /// + /// Enriches (or creates) the essentials entry from the loaded PepperDash.Essentials.Core assembly + /// + private static void PopulateEssentials(VersionData result) + { + var essentials = result.Essentials ?? new NugetVersion(); + + essentials.Version = Global.AssemblyVersion; + + // The main program assembly (PackageId "PepperDashEssentials") is what's actually published + // to NuGet, but this handler lives in PepperDash.Essentials.Core, which can't reference that + // project's types directly (Essentials -> Core, not the reverse). PluginLoader.EssentialsAssembly.Assembly + // is unreliable (often left null - see PluginLoader.SetEssentialsAssembly), so look it up + // directly from the loaded AppDomain by its Directory.Build.props-embedded PackageId metadata + // (every project's .csproj sets its own PackageId explicitly), falling back to this handler's + // own (Core) assembly if it can't be found. + var essentialsAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(GetAssemblyMetadataValue(a, "PackageId"), "PepperDashEssentials", StringComparison.OrdinalIgnoreCase)) + ?? typeof(GetPackageManifestRequestHandler).Assembly; + + var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl")); + if (!string.IsNullOrEmpty(repoUrl)) + { + essentials.RepoUrl = repoUrl; + } + + var name = GetAssemblyProduct(essentialsAssembly); + if (!string.IsNullOrEmpty(name)) + { + essentials.Name = name; + } + + // Prefer the PackageId embedded via Directory.Build.props' AssemblyMetadata item (the + // authoritative source, matching the actual published PackageId) over any config-supplied + // or hardcoded value. + var reflectedPackageId = GetAssemblyMetadataValue(essentialsAssembly, "PackageId"); + if (!string.IsNullOrEmpty(reflectedPackageId)) + { + essentials.PackageId = reflectedPackageId; + } + else if (string.IsNullOrEmpty(essentials.PackageId)) + { + essentials.PackageId = "PepperDashEssentials"; + } + + result.Essentials = essentials; + } + + /// + /// Merges reflection data from loaded plugin assemblies with the config's packages list + /// + private static void PopulatePackages(VersionData result) + { + // Filter out null entries defensively - the packages list is deserialized from user-editable + // config JSON, so a malformed "packages": [null, ...] shouldn't throw and 500 the endpoint. + var configPackages = (result.Packages ?? new System.Collections.Generic.List()) + .Where(p => p != null) + .ToList(); + var matchedConfigPackages = new System.Collections.Generic.HashSet(); + var mergedPackages = new System.Collections.Generic.List(); + + foreach (var loaded in PluginLoader.EssentialsPluginAssemblies.Where(a => a.Assembly != null)) + { + var reflectedVersion = loaded.Version; + if (string.IsNullOrEmpty(reflectedVersion)) + { + // Never emit an entry with no version - the extension's parser drops entries + // whose version isn't a string. + continue; + } + + var reflectedRepoUrl = TrimTrailingGit(GetAssemblyMetadataValue(loaded.Assembly, "RepositoryUrl")); + var reflectedName = GetAssemblyProduct(loaded.Assembly); + + // Plugins built from a Directory.Build.props that embeds + // carry their PackageId + // directly - this is authoritative and should be preferred over the title/name fallback chain. + var reflectedPackageId = GetAssemblyMetadataValue(loaded.Assembly, "PackageId"); + + var assemblyTitle = GetAssemblyTitle(loaded.Assembly); + var assemblyName = loaded.Assembly.GetName().Name; + var assemblyNameNoSeriesSuffix = StripTrailingSeriesSuffix(assemblyName); + + var match = configPackages.FirstOrDefault(p => + !matchedConfigPackages.Contains(p) && + !string.IsNullOrEmpty(p.PackageId) && + (string.Equals(p.PackageId, reflectedPackageId, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyTitle, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyName, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyNameNoSeriesSuffix, StringComparison.OrdinalIgnoreCase))); + + if (match != null) + { + matchedConfigPackages.Add(match); + + mergedPackages.Add(new NugetVersion + { + Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName, + RepoUrl = !string.IsNullOrEmpty(match.RepoUrl) ? match.RepoUrl : reflectedRepoUrl, + PackageId = !string.IsNullOrEmpty(reflectedPackageId) ? reflectedPackageId : match.PackageId, + Version = reflectedVersion + }); + } + else + { + // Loaded but not present (or not matched) in config - emit the reflected PackageId + // when the assembly carries one, otherwise leave it null as before. + mergedPackages.Add(new NugetVersion + { + Name = reflectedName, + RepoUrl = reflectedRepoUrl, + PackageId = reflectedPackageId, + Version = reflectedVersion, + }); + } + } + + // Configured but not currently loaded - pass through unchanged + mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p))); + + result.Packages = mergedPackages; + } + + private static string GetAssemblyMetadataValue(Assembly assembly, string key) + { + if (assembly == null) + { + return null; + } + + var match = assembly.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false) + .Cast() + .FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase)); + + return match?.Value; + } + + private static string GetAssemblyProduct(Assembly assembly) + { + if (assembly == null) + { + return null; + } + + var attribute = assembly.GetCustomAttributes(typeof(AssemblyProductAttribute), false) + .FirstOrDefault() as AssemblyProductAttribute; + + return attribute?.Product; + } + + private static string GetAssemblyTitle(Assembly assembly) + { + if (assembly == null) + { + return null; + } + + var attribute = assembly.GetCustomAttributes(typeof(AssemblyTitleAttribute), false) + .FirstOrDefault() as AssemblyTitleAttribute; + + return attribute?.Title; + } + + private static string StripTrailingSeriesSuffix(string assemblyName) + { + const string suffix = ".4Series"; + + if (string.IsNullOrEmpty(assemblyName) || !assemblyName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return assemblyName; + } + + return assemblyName.Substring(0, assemblyName.Length - suffix.Length); + } + + private static string TrimTrailingGit(string repoUrl) + { + const string suffix = ".git"; + + if (string.IsNullOrEmpty(repoUrl) || !repoUrl.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return repoUrl; + } + + return repoUrl.Substring(0, repoUrl.Length - suffix.Length); + } + } +} diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs new file mode 100644 index 00000000..0d65b0e3 --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutingDevicesAndTieLinesHandler.cs @@ -0,0 +1,178 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core; +using PepperDash.Core.Web.RequestHandlers; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Handles HTTP requests to retrieve routing devices and tielines information + /// + public class GetRoutingDevicesAndTieLinesHandler : WebApiBaseRequestHandler + { + public GetRoutingDevicesAndTieLinesHandler() : base(true) { } + + protected override void HandleGet(HttpCwsContext context) + { + var devices = new List(); + + // Get all devices from DeviceManager + foreach (var device in DeviceManager.AllDevices) + { + var deviceInfo = new RoutingDeviceInfo + { + Key = device.Key, + Name = (device as IKeyName)?.Name ?? device.Key + }; + + // Check if device implements IRoutingInputs + if (device is IRoutingInputs inputDevice) + { + deviceInfo.HasInputs = true; + deviceInfo.InputPorts = inputDevice.InputPorts.Select(p => new PortInfo + { + Key = p.Key, + SignalType = p.Type.ToString(), + ConnectionType = p.ConnectionType.ToString(), + IsInternal = p.IsInternal + }).ToList(); + } + + // Check if device implements IRoutingOutputs + if (device is IRoutingOutputs outputDevice) + { + deviceInfo.HasOutputs = true; + deviceInfo.OutputPorts = outputDevice.OutputPorts.Select(p => new PortInfo + { + Key = p.Key, + SignalType = p.Type.ToString(), + ConnectionType = p.ConnectionType.ToString(), + IsInternal = p.IsInternal + }).ToList(); + } + + // Check if device implements IRoutingInputsOutputs + if (device is IRoutingInputsOutputs) + { + deviceInfo.HasInputsAndOutputs = true; + } + + // Only include devices that have routing capabilities + if (deviceInfo.HasInputs || deviceInfo.HasOutputs) + { + devices.Add(deviceInfo); + } + } + + // Get all tielines + var tielines = TieLineCollection.Default.Select(tl => new TieLineInfo + { + SourceDeviceKey = tl.SourcePort.ParentDevice.Key, + SourcePortKey = tl.SourcePort.Key, + DestinationDeviceKey = tl.DestinationPort.ParentDevice.Key, + DestinationPortKey = tl.DestinationPort.Key, + SignalType = tl.Type.ToString(), + IsInternal = tl.IsInternal + }).ToList(); + + var response = new RoutingSystemInfo + { + Devices = devices, + TieLines = tielines + }; + + var jsonResponse = JsonConvert.SerializeObject(response, Formatting.Indented); + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = Encoding.UTF8; + context.Response.Write(jsonResponse, false); + context.Response.End(); + } + } + + /// + /// Represents the complete routing system information including devices and tielines + /// + public class RoutingSystemInfo + { + [JsonProperty("devices")] + public List Devices { get; set; } + + [JsonProperty("tieLines")] + public List TieLines { get; set; } + } + + /// + /// Represents a routing device with its ports information + /// + public class RoutingDeviceInfo + { + [JsonProperty("key")] + public string Key { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("hasInputs")] + public bool HasInputs { get; set; } + + [JsonProperty("hasOutputs")] + public bool HasOutputs { get; set; } + + [JsonProperty("hasInputsAndOutputs")] + public bool HasInputsAndOutputs { get; set; } + + [JsonProperty("inputPorts", NullValueHandling = NullValueHandling.Ignore)] + public List InputPorts { get; set; } + + [JsonProperty("outputPorts", NullValueHandling = NullValueHandling.Ignore)] + public List OutputPorts { get; set; } + } + + /// + /// Represents a routing port with its properties + /// + public class PortInfo + { + [JsonProperty("key")] + public string Key { get; set; } + + [JsonProperty("signalType")] + public string SignalType { get; set; } + + [JsonProperty("connectionType")] + public string ConnectionType { get; set; } + + [JsonProperty("isInternal")] + public bool IsInternal { get; set; } + } + + /// + /// Represents a tieline connection between two ports + /// + public class TieLineInfo + { + [JsonProperty("sourceDeviceKey")] + public string SourceDeviceKey { get; set; } + + [JsonProperty("sourcePortKey")] + public string SourcePortKey { get; set; } + + [JsonProperty("destinationDeviceKey")] + public string DestinationDeviceKey { get; set; } + + [JsonProperty("destinationPortKey")] + public string DestinationPortKey { get; set; } + + [JsonProperty("signalType")] + public string SignalType { get; set; } + + [JsonProperty("isInternal")] + public bool IsInternal { get; set; } + } +} diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs new file mode 100644 index 00000000..949f8bd5 --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -0,0 +1,173 @@ + +using System; +using System.Collections.Generic; +using Crestron.SimplSharp.CrestronAuthentication; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core.Web.RequestHandlers; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Represents a LoginRequestHandler + /// + public class LoginRequestHandler : WebApiBaseRequestHandler + { + /// + /// Constructor + /// + /// + /// base(true) enables CORS support by default + /// + public LoginRequestHandler() + : base(true) + { + } + + /// + /// Handles POST method requests for user login and token generation + /// + /// The HTTP context for the request. + protected override void HandlePost(HttpCwsContext context) + { + try + { + if (context.Request.ContentLength < 0) + { + context.Response.StatusCode = 400; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + var data = context.Request.GetRequestBody(); + if (string.IsNullOrEmpty(data)) + { + context.Response.StatusCode = 400; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + var loginRequest = JsonConvert.DeserializeObject(data); + + if (loginRequest == null || string.IsNullOrEmpty(loginRequest.Username) || string.IsNullOrEmpty(loginRequest.Password)) + { + context.Response.StatusCode = 400; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + Authentication.UserToken token; + + try + { + token = Authentication.GetAuthenticationToken(loginRequest.Username, loginRequest.Password); + } + catch (ArgumentException) + { + context.Response.StatusCode = 401; + context.Response.StatusDescription = "Bad Request"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(JsonConvert.SerializeObject(new { Error = "Unauthorized" }, Formatting.Indented), false); + context.Response.End(); + return; + } + + if (!token.Valid) + { + context.Response.StatusCode = 401; + context.Response.StatusDescription = "Unauthorized"; + context.Response.End(); + + return; + } + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(JsonConvert.SerializeObject( + new + { + Token = new LoginResponse + { + UserName = token.UserName, + Access = token.Access, + State = token.State, + Groups = token.Groups, + ADConnect = token.ADConnect, + Valid = token.Valid + } + }, Formatting.Indented), false); + context.Response.End(); + } + catch (System.Exception ex) + { + context.Response.StatusCode = 500; + context.Response.StatusDescription = "Internal Server Error"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(JsonConvert.SerializeObject(new { Error = ex.Message }, Formatting.Indented), false); + context.Response.End(); + } + } + } + + /// + /// Represents a LoginRequest + /// + public class LoginRequest + { + /// + /// Gets or sets the username. + /// + public string Username { get; set; } + + /// + /// Gets or sets the password. + /// + public string Password { get; set; } + } + + /// + /// Represents a LoginResponse + /// + internal class LoginResponse + { + /// + /// Gets or sets the username. + /// + public string UserName { get; set; } + + /// + /// Gets or sets the access level. + /// + public Authentication.UserAuthenticationLevelEnum Access { get; set; } + + /// + /// Gets or sets the token authenticated state. + /// + public Authentication.eTokenAuthenticatedState State { get; set; } + + /// + /// Gets or sets the list of groups. + /// + public List Groups { get; set; } + + /// + /// Gets or sets the active directory connection flag. + /// + public int ADConnect { get; set; } + + /// + /// Gets or sets the valid flag indicating whether the token is valid. + /// + public bool Valid { get; set; } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs new file mode 100644 index 00000000..d8a296e5 --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronIO; +using Crestron.SimplSharp.WebScripting; +using PepperDash.Core; +using PepperDash.Core.Web.RequestHandlers; +using Serilog.Events; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Serves the React debug app from the processor's /HTML/debug folder. + /// The root route (debug) and all sub-paths (debug/{*filePath}) are handled here. + /// Text assets are sent as UTF-8 strings; binary assets are written to the response + /// OutputStream. Any sub-path that does not match a real file falls back to + /// index.html so that client-side (React Router) routing continues to work. + /// + public class ServeDebugAppRequestHandler : WebApiBaseRequestHandler + { + private static readonly Dictionary MimeTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { ".html", "text/html; charset=utf-8" }, + { ".htm", "text/html; charset=utf-8" }, + { ".js", "application/javascript" }, + { ".mjs", "application/javascript" }, + { ".jsx", "application/javascript" }, + { ".css", "text/css" }, + { ".json", "application/json" }, + { ".map", "application/json" }, + { ".svg", "image/svg+xml" }, + { ".ico", "image/x-icon" }, + { ".png", "image/png" }, + { ".jpg", "image/jpeg" }, + { ".jpeg", "image/jpeg" }, + { ".gif", "image/gif" }, + { ".woff", "font/woff" }, + { ".woff2","font/woff2" }, + { ".ttf", "font/ttf" }, + { ".eot", "application/vnd.ms-fontobject" }, + }; + + private static readonly HashSet TextExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".html", ".htm", ".js", ".mjs", ".jsx", ".css", ".json", ".map", ".svg", ".txt", ".xml" + }; + + /// + /// Constructor. CORS is enabled so browser dev-tools requests succeed. + /// + public ServeDebugAppRequestHandler() : base(true) { } + + /// + /// Handles GET requests for the debug app and its static assets. + /// + protected override void HandleGet(HttpCwsContext context) + { + // When acting as the server-level fallback handler, only handle + // requests that are actually for the /debug path; defer everything + // else to the base class (which returns 501 Not Implemented). + var rawUrl = context.Request.RawUrl ?? string.Empty; + if (rawUrl.IndexOf("/debug", StringComparison.OrdinalIgnoreCase) < 0) + { + base.HandleGet(context); + return; + } + + try + { + var htmlDebugPath = GetHtmlDebugPath(); + if (htmlDebugPath == null) + { + SendResponse(context, 500, "Internal Server Error"); + return; + } + + var requestedPath = GetRequestedFilePath(context); + + // Paths with no file extension are SPA client-side routes — serve index.html + string candidate; + if (string.IsNullOrEmpty(requestedPath) || !System.IO.Path.HasExtension(requestedPath)) + { + candidate = System.IO.Path.Combine(htmlDebugPath, "index.html"); + } + else + { + var relativePart = requestedPath.Replace('/', System.IO.Path.DirectorySeparatorChar); + candidate = System.IO.Path.Combine(htmlDebugPath, relativePart); + } + + // Resolve to an absolute path and guard against path-traversal attacks + var resolvedCandidate = System.IO.Path.GetFullPath(candidate); + var resolvedBase = System.IO.Path.GetFullPath(htmlDebugPath) + + System.IO.Path.DirectorySeparatorChar; + + if (!resolvedCandidate.StartsWith(resolvedBase, StringComparison.OrdinalIgnoreCase)) + { + SendResponse(context, 403, "Forbidden"); + return; + } + + // Missing static asset → fall back to index.html (SPA deep-link support) + if (!File.Exists(resolvedCandidate) && System.IO.Path.HasExtension(requestedPath ?? string.Empty)) + { + resolvedCandidate = System.IO.Path.Combine(htmlDebugPath, "index.html"); + Debug.LogMessage(LogEventLevel.Debug, + "ServeDebugAppRequestHandler: '{requestedPath:l}' not found, falling back to index.html", + requestedPath); + } + + if (!File.Exists(resolvedCandidate)) + { + SendResponse(context, 404, "Not Found"); + return; + } + + var ext = System.IO.Path.GetExtension(resolvedCandidate); + var contentType = MimeTypes.TryGetValue(ext, out var mime) ? mime : "application/octet-stream"; + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = contentType; + + if (TextExtensions.Contains(ext)) + { + string content; + using (var reader = new StreamReader(resolvedCandidate, Encoding.UTF8)) + content = reader.ReadToEnd(); + + context.Response.ContentEncoding = Encoding.UTF8; + context.Response.Write(content, false); + } + else + { + var bytes = System.IO.File.ReadAllBytes(resolvedCandidate); + context.Response.OutputStream.Write(bytes, 0, bytes.Length); + } + + context.Response.End(); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Error, ex, + "ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}'", + context.Request.RawUrl); + try { SendResponse(context, 500, "Internal Server Error"); } + catch { /* best-effort */ } + } + } + + /// + /// Resolves the absolute path of the /HTML/debug folder on the processor. + /// + /// + /// Global.FilePathPrefix is always {root}/user/programX/ (or + /// equivalent), so walking up two parents gives the processor root that + /// contains the html folder. This mirrors the two-hop strategy used + /// by AssetLoader.ExtractDevToolsZip so that serving and extraction + /// always resolve to the same directory. + /// + private static string GetHtmlDebugPath() + { + try + { + var separators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; + var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(separators)); + + // Walk up two levels: {root}/user/programX/ → {root}/user/ → {root} + // This matches the path calculation used in AssetLoader.ExtractDevToolsZip. + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; + + if (rootDir == null) + { + Debug.LogMessage(LogEventLevel.Error, + "ServeDebugAppRequestHandler: Cannot resolve HTML root from FilePathPrefix '{prefix:l}'", + Global.FilePathPrefix); + return null; + } + + return System.IO.Path.Combine(rootDir.FullName, "html", "debug"); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Error, ex, + "ServeDebugAppRequestHandler: Error resolving HTML debug path"); + return null; + } + } + + /// + /// Extracts the file sub-path from the request by parsing RawUrl. + /// Returns an empty string when the URL ends at /debug (root hit). + /// + private static string GetRequestedFilePath(HttpCwsContext context) + { + var rawUrl = context.Request.RawUrl ?? string.Empty; + + // Locate the /debug segment in the URL + const string debugToken = "/debug"; + var idx = rawUrl.IndexOf(debugToken, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + return string.Empty; + + var afterDebug = rawUrl.Substring(idx + debugToken.Length); + + // Strip query string + var qIdx = afterDebug.IndexOf('?'); + if (qIdx >= 0) + afterDebug = afterDebug.Substring(0, qIdx); + + // Strip leading slash to get a relative file path + return afterDebug.TrimStart('/'); + } + + private static void SendResponse(HttpCwsContext context, int statusCode, string statusDescription) + { + context.Response.StatusCode = statusCode; + context.Response.StatusDescription = statusDescription; + context.Response.End(); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs b/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs index 126bcd27..f383dd0a 100644 --- a/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs +++ b/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs @@ -17,6 +17,8 @@ namespace PepperDash.Essentials.Devices.Common.Cameras /// /// Represents a CameraVisca /// + [Obsolete("CameraVisca is no longer supported and will be removed in a future release. Use the CameraVisca plugin instead.")] + public class CameraVisca : CameraBase, IHasCameraPtzControl, ICommunicationMonitor, IHasCameraPresets, IHasPowerControlWithFeedback, IBridgeAdvanced, IHasCameraFocusControl, IHasAutoFocusMode { private readonly CameraViscaPropertiesConfig PropertiesConfig; diff --git a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs index 80c1f8ee..d542f52a 100644 --- a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs +++ b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs @@ -152,6 +152,15 @@ namespace PepperDash.Essentials.Devices.Common.Shades private void IsCoolingDownFeedback_OutputChange(object sender, FeedbackEventArgs e) { + if (Config.DisableAutoRaiseOnPowerOff) + { + this.LogDebug( + "Auto-raise on power-off disabled for {type}; leaving position unchanged (manual control only)", + Type + ); + return; + } + if ( !DisplayDevice.IsCoolingDownFeedback.BoolValue && Type == eScreenLiftControlType.lift @@ -174,6 +183,15 @@ namespace PepperDash.Essentials.Devices.Common.Shades { if (DisplayDevice.IsWarmingUpFeedback.BoolValue) { + if (Config.DisableAutoLowerOnPowerOn) + { + this.LogDebug( + "Auto-lower on power-on disabled for {type}; leaving position unchanged (manual control only)", + Type + ); + return; + } + Lower(); } } diff --git a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs index 1c4f9906..88113a08 100644 --- a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs +++ b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs @@ -41,5 +41,23 @@ namespace PepperDash.Essentials.Devices.Common.Shades /// [JsonProperty("muteOnScreenUp")] public bool MuteOnScreenUp { get; set; } + + /// + /// When true, this controller does NOT automatically lower when its assigned display powers on + /// (warms up). Manual Raise/Lower still work, and the power-off auto-raise is unaffected. Intended + /// for a projector screen that must not auto-drop in a public space for safety, while the projector + /// lift (a separate controller) can still drop automatically. + /// + [JsonProperty("disableAutoLowerOnPowerOn")] + public bool DisableAutoLowerOnPowerOn { get; set; } + + /// + /// When true, this controller does NOT automatically raise when its assigned display powers off + /// (cools down). Manual Raise/Lower still work, and the power-on auto-lower is unaffected. The + /// companion to ; together they make a controller fully + /// manual while leaving other controllers (e.g. the lift) on their default automatic behavior. + /// + [JsonProperty("disableAutoRaiseOnPowerOff")] + public bool DisableAutoRaiseOnPowerOff { get; set; } } } diff --git a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj index 7be4372d..317fa2d3 100644 --- a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj +++ b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj @@ -29,6 +29,6 @@ - + \ No newline at end of file diff --git a/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs b/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs index e9f7908c..bdefa09d 100644 --- a/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs +++ b/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs @@ -21,7 +21,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec /// /// Represents a MockVC /// - public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets + public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets, IRoutingInputs { /// /// Gets or sets the PropertiesConfig diff --git a/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs b/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs index b0b5ef55..04c7e64d 100644 --- a/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs +++ b/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs @@ -26,7 +26,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec /// /// Base class for video codec devices /// - public abstract class VideoCodecBase : ReconfigurableDevice, IRoutingInputsOutputs, + public abstract class VideoCodecBase : ReconfigurableDevice, IUsageTracking, IHasDialer, IHasContentSharing, ICodecAudio, iVideoCodecInfo, IBridgeAdvanced, IHasStandbyMode { private const int XSigEncoding = 28591; diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs index 54a6ec36..0198df2f 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs @@ -10,7 +10,7 @@ namespace PepperDash.Essentials.AppServer.Messengers /// /// The device key /// - [JsonProperty("key")] + [JsonProperty("key", NullValueHandling = NullValueHandling.Ignore)] /// /// Gets or sets the Key /// @@ -19,19 +19,19 @@ namespace PepperDash.Essentials.AppServer.Messengers /// /// The device name /// - [JsonProperty("name")] + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } /// /// The type of the message class /// - [JsonProperty("messageType")] + [JsonProperty("messageType", NullValueHandling = NullValueHandling.Ignore)] public string MessageType => GetType().Name; /// /// Gets or sets the MessageBasePath /// - [JsonProperty("messageBasePath")] + [JsonProperty("messageBasePath", NullValueHandling = NullValueHandling.Ignore)] public string MessageBasePath { get; set; } } diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs index 87f19e3f..c5c0ab65 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Newtonsoft.Json; @@ -11,13 +12,15 @@ namespace PepperDash.Essentials.AppServer.Messengers /// /// The interfaces implmented by the device sending the messsage /// - [JsonProperty("interfaces")] + [JsonProperty("interfaces", NullValueHandling = NullValueHandling.Ignore)] + [Obsolete("Interfaces are no longer supported and will be removed in a future release. Interfaces for all devices are now retrieved via the /joinroom endpoint in the MobileControlWebsocketServer")] public List Interfaces { get; private set; } /// /// Sets the interfaces implemented by the device sending the message /// /// + [Obsolete("SetInterfaces is no longer supported and will be removed in a future release. Interfaces for all devices are now retrieved via the /joinroom endpoint in the MobileControlWebsocketServer")] public void SetInterfaces(List interfaces) { Interfaces = interfaces; diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs index 3031f4ba..eb3afec3 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs @@ -264,6 +264,9 @@ namespace PepperDash.Essentials.AppServer.Messengers message.Name = _device.Name; + message.MessageBasePath = MessagePath; + + var token = JToken.FromObject(message); PostStatusMessage(token, MessagePath, clientId); diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj index d13d1a09..a9223061 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj +++ b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj @@ -33,7 +33,7 @@ - + diff --git a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs new file mode 100644 index 00000000..68f5e36e --- /dev/null +++ b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs @@ -0,0 +1,61 @@ +using System; +using Newtonsoft.Json; + +namespace PepperDash.Essentials +{ + /// + /// Represents the version information reported by a connected Mobile Control UI client + /// + public class ConnectedClientVersionInfo + { + /// + /// Gets or sets the client id + /// + [JsonProperty("clientId")] + public string ClientId { get; set; } + + /// + /// Gets or sets the room key the client joined + /// + [JsonProperty("roomKey")] + public string RoomKey { get; set; } + + /// + /// Gets or sets the touchpanel key the client joined as, if any + /// + [JsonProperty("touchpanelKey")] + public string TouchpanelKey { get; set; } + + /// + /// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION) + /// + [JsonProperty("appVersion")] + public string AppVersion { get; set; } + + /// + /// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured + /// + [JsonProperty("expectedAppVersion")] + public string ExpectedAppVersion { get; set; } + + /// + /// Gets or sets the UTC time the client last reported this version + /// + [JsonProperty("lastSeen")] + public DateTime LastSeen { get; set; } + + /// + /// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate + /// without affecting the internally tracked instance + /// + public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo + { + ClientId = ClientId, + RoomKey = RoomKey, + TouchpanelKey = TouchpanelKey, + AppVersion = AppVersion, + ExpectedAppVersion = ExpectedAppVersion, + LastSeen = LastSeen + }; + } +} diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs index ec7219a3..963e7fd5 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -40,9 +41,10 @@ namespace PepperDash.Essentials public bool EnableApiServer { get; set; } = true; /// - /// Enable subscriptions for Messengers + /// Enables subscriptions for messengers /// [JsonProperty("enableMessengerSubscriptions")] + [Obsolete("This property is obsolete and will be removed in a future version. All messengers are now subscription based.")] public bool EnableMessengerSubscriptions { get; set; } } @@ -288,4 +290,4 @@ namespace PepperDash.Essentials /// NEO } -} \ No newline at end of file +} diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index 3adcaf87..c17cf3b9 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -69,11 +69,32 @@ namespace PepperDash.Essentials private readonly Dictionary _defaultMessengers = new Dictionary(); + private readonly Dictionary _connectedClientVersions = + new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + private readonly object _connectedClientVersionsLock = new object(); + /// /// Get the custom messengers with subscriptions /// public ReadOnlyDictionary Messengers => new ReadOnlyDictionary(_messengers.Values.OfType().ToDictionary(k => k.Key, v => v)); + /// + /// Gets the most recently reported UI app version for each connected client, keyed by clientId + /// + public ReadOnlyDictionary ConnectedClientVersions + { + get + { + lock (_connectedClientVersionsLock) + { + return new ReadOnlyDictionary( + _connectedClientVersions.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()) + ); + } + } + } + /// /// Get the default messengers /// @@ -137,7 +158,7 @@ namespace PepperDash.Essentials "No system_url value defined in config. Checking for value from SIMPL Bridge." ); - if (!string.IsNullOrEmpty(SystemUrl)) + if (string.IsNullOrEmpty(SystemUrl)) { this.LogError( "No system_url value defined in config or SIMPL Bridge. Unable to connect to Mobile Control." @@ -1782,6 +1803,28 @@ namespace PepperDash.Essentials " Not Enabled in Config.\r\n" ); } + + var connectedClientVersions = ConnectedClientVersions; + + if (connectedClientVersions.Count == 0) + { + CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions: None reported yet\r\n"); + } + else + { + CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions:\r\n"); + foreach (var kv in connectedClientVersions) + { + var v = kv.Value; + var match = string.IsNullOrEmpty(v.ExpectedAppVersion) || string.Equals(v.ExpectedAppVersion, v.AppVersion, StringComparison.OrdinalIgnoreCase); + + CrestronConsole.ConsoleCommandResponse( + $" Client: {v.ClientId} Touchpanel: {v.TouchpanelKey} Room: {v.RoomKey}\r\n" + + $" Reported: {v.AppVersion} Expected: {(string.IsNullOrEmpty(v.ExpectedAppVersion) ? "(not configured)" : v.ExpectedAppVersion)} Match: {(match ? "Yes" : "NO - MISMATCH")}\r\n" + + $" Last Seen (UTC): {v.LastSeen:yyyy-MM-dd HH:mm:ss}\r\n" + ); + } + } } /// @@ -2181,6 +2224,8 @@ namespace PepperDash.Essentials var roomKey = content["roomKey"].Value(); var touchpanelKey = content.SelectToken("touchpanelKey"); + TrackClientAppVersion(clientId, roomKey, touchpanelKey?.Value(), content.SelectToken("appVersion")?.Value()); + if (_roomCombiner == null) { var message = new MobileControlMessage @@ -2252,6 +2297,50 @@ namespace PepperDash.Essentials SendTouchpanelKey(clientId, touchpanelKey); } + /// + /// Records the app version reported by a connecting UI client (e.g. the mobile control React app's + /// build-time APP_VERSION) and compares it against the configured versions.touchpanelWrapperApp version. + /// + private void TrackClientAppVersion(string clientId, string roomKey, string touchpanelKey, string appVersion) + { + if (string.IsNullOrEmpty(appVersion)) + { + return; + } + + var expectedVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version; + + var info = new ConnectedClientVersionInfo + { + ClientId = clientId, + RoomKey = roomKey, + TouchpanelKey = touchpanelKey, + AppVersion = appVersion, + ExpectedAppVersion = expectedVersion, + LastSeen = DateTime.UtcNow + }; + + lock (_connectedClientVersionsLock) + { + _connectedClientVersions[clientId] = info; + } + + if (!string.IsNullOrEmpty(expectedVersion) && !string.Equals(expectedVersion, appVersion, StringComparison.OrdinalIgnoreCase)) + { + this.LogWarning( + "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}, which does not match configured versions.touchpanelWrapperApp version {expectedVersion}", + clientId, touchpanelKey, appVersion, expectedVersion + ); + } + else + { + this.LogVerbose( + "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}", + clientId, touchpanelKey, appVersion + ); + } + } + private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken) { if (touchpanelKeyToken == null) diff --git a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj index 235e0899..b0e2dd9f 100644 --- a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj +++ b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj @@ -38,7 +38,7 @@ - + diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index 5830782d..262cb34e 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -25,11 +25,28 @@ namespace PepperDash.Essentials.Touchpanel /// Mobile Control touchpanel controller that provides app control, Zoom integration, /// and mobile control functionality for Crestron touchpanels. /// - public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme + public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme, ICommunicationMonitor { private readonly MobileControlTouchpanelProperties localConfig; private IMobileControlRoomMessenger _bridge; + /// + /// Gets the CommunicationMonitor tracking the panel's online/offline state + /// + public StatusMonitorBase CommunicationMonitor { get; private set; } + + private sealed class NullCommunicationMonitor : StatusMonitorBase + { + public NullCommunicationMonitor(IKeyed parent) : base(parent, 120000, 300000) + { + Status = MonitorStatus.InError; + Message = "Panel is not initialized"; + } + + public override void Start() { } + public override void Stop() { } + } + private string _appUrl; /// @@ -128,6 +145,11 @@ namespace PepperDash.Essentials.Touchpanel { localConfig = config; + if (panel != null) + { + CommunicationMonitor = new CrestronGenericBaseCommunicationMonitor(this, panel, 120000, 300000); + } + AddPostActivationAction(SubscribeForMobileControlUpdates); ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme); @@ -366,6 +388,8 @@ namespace PepperDash.Essentials.Touchpanel /// public override bool CustomActivate() { + CommunicationMonitor?.Start(); + var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this); var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this); @@ -393,6 +417,17 @@ namespace PepperDash.Essentials.Touchpanel return base.CustomActivate(); } + /// + /// Stops the CommunicationMonitor on deactivation. + /// + /// True if deactivation was successful; otherwise, false. + public override bool Deactivate() + { + CommunicationMonitor?.Stop(); + + return base.Deactivate(); + } + /// /// Handles device extender signal changes for system reserved signals. /// @@ -519,15 +554,8 @@ namespace PepperDash.Essentials.Touchpanel return false; }) ? csIpAddress.ToString() : processorIp; - var match = Regex.Match(url, @"^http://([^:/]+):\d+/mc/app\?token=.+$"); - if (match.Success) - { - string ipa = match.Groups[1].Value; - // ip will be "192.168.1.100" - } - - // replace ipa with ip but leave the rest of the string intact - var updatedUrl = Regex.Replace(url, @"^http://[^:/]+", $"http://{ip}"); + // replace the host but preserve whatever scheme (http/https) is already present in the URL + var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}"); this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl); @@ -740,7 +768,7 @@ namespace PepperDash.Essentials.Touchpanel /// public MobileControlTouchpanelControllerFactory() { - TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mcxpanel", "mcdge1000" }; + TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mctsw1080", "mcts1080", "mcxpanel", "mcdge1000" }; MinimumEssentialsFrameworkVersion = "2.0.0"; factories = new Dictionary> @@ -765,6 +793,8 @@ namespace PepperDash.Essentials.Touchpanel {"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)}, {"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)}, {"ts1070", (id, controlSystem, projectName) => new Ts1070(id, controlSystem)}, + {"tsw1080", (id, controlSystem, projectName) => new Tsw1080(id, controlSystem)}, + {"ts1080", (id, controlSystem, projectName) => new Ts1080(id, controlSystem)}, {"dge1000", (id, controlSystem, projectName) => new Dge1000(id, controlSystem)} }; } diff --git a/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs b/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs new file mode 100644 index 00000000..fd5f9844 --- /dev/null +++ b/src/PepperDash.Essentials.MobileControl/WebApiHandlers/DeleteAllUiClientsHandler.cs @@ -0,0 +1,41 @@ +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.WebSocketServer; + + +namespace PepperDash.Essentials.WebApiHandlers +{ + /// + /// Represents a DeleteAllUiClientsHandler + /// + public class DeleteAllUiClientsHandler : WebApiBaseRequestHandler + { + private readonly MobileControlWebsocketServer server; + + /// + /// Essentials CWS API handler for the MC Direct Server + /// + /// Direct Server instance + public DeleteAllUiClientsHandler(MobileControlWebsocketServer directServer) : base(true) + { + server = directServer; + } + + /// + /// Deletes all clients from the Direct Server + /// + /// HTTP Context for this request + protected override void HandleDelete(HttpCwsContext context) + { + server.RemoveAllTokens("confirm"); + + var res = context.Response; + res.StatusCode = 200; + res.ContentType = "application/json"; + res.Headers.Add("Content-Type", "application/json"); + res.Write(JsonConvert.SerializeObject(new { success = true }), false); + res.End(); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index 3b55120f..c96dc116 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -127,6 +127,16 @@ namespace PepperDash.Essentials.WebSocketServer /// public int Port { get; private set; } + /// + /// Gets the HTTP scheme to use for generated URLs, based on whether the direct server is configured as secure + /// + private string HttpScheme => _parent.Config.DirectServer.Secure ? "https" : "http"; + + /// + /// Gets the WebSocket scheme to use for generated URLs, based on whether the direct server is configured as secure + /// + private string WsScheme => _parent.Config.DirectServer.Secure ? "wss" : "ws"; + /// /// Gets the user app URL prefix /// @@ -134,7 +144,8 @@ namespace PepperDash.Essentials.WebSocketServer { get { - return string.Format("http://{0}:{1}{2}?token=", + return string.Format("{0}://{1}:{2}{3}?token=", + HttpScheme, CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), Port, _userAppBaseHref); @@ -238,11 +249,17 @@ namespace PepperDash.Essentials.WebSocketServer var routes = new List { - new HttpCwsRoute($"devices/{Key}/client") + new HttpCwsRoute($"device/{Key}/client") { Name = "ClientHandler", RouteHandler = new UiClientHandler(this) }, + + new HttpCwsRoute($"device/{Key}/deleteAllUiClients") + { + Name = "DeleteAllClientsHandler", + RouteHandler = new DeleteAllUiClientsHandler(this) + }, }; apiServer.AddRoute(routes); @@ -267,7 +284,7 @@ namespace PepperDash.Essentials.WebSocketServer { base.Initialize(); - _server = new HttpServer(Port, false); + _server = new HttpServer(Port, _parent.Config.DirectServer.Secure); _server.OnGet += Server_OnGet; @@ -285,7 +302,7 @@ namespace PepperDash.Essentials.WebSocketServer { ClientCertificateRequired = false, CheckCertificateRevocation = false, - EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 + EnabledSslProtocols = SslProtocols.Tls12 }; } @@ -397,11 +414,11 @@ namespace PepperDash.Essentials.WebSocketServer ip = csIpAddress.ToString(); } - var appUrl = $"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"; + var appUrl = $"{HttpScheme}://{ip}:{Port}/mc/app?token={touchpanel.Key}"; this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key); - touchpanel.Touchpanel.SetAppUrl($"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"); + touchpanel.Touchpanel.SetAppUrl(appUrl); } } @@ -481,7 +498,7 @@ namespace PepperDash.Essentials.WebSocketServer { var config = new MobileControlApplicationConfig { - ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port), + ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, Port), GatewayAppPath = "", LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png", EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false, @@ -908,7 +925,7 @@ namespace PepperDash.Essentials.WebSocketServer /// /// Removes all clients from the server /// - private void RemoveAllTokens(string s) + public void RemoveAllTokens(string s) { if (s == "?" || string.IsNullOrEmpty(s)) { @@ -1092,6 +1109,7 @@ namespace PepperDash.Essentials.WebSocketServer res.StatusCode = 200; res.Close(); + // remote log collector has no dedicated secure flag; keep it on http regardless of DirectServer.Secure var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") { Content = new StringContent(body, Encoding.UTF8, "application/json"), @@ -1144,6 +1162,11 @@ namespace PepperDash.Essentials.WebSocketServer var qp = req.QueryString; var token = qp["token"]; + // Each join mints a single-use clientId; the panel webview must never replay a cached + // response, or it reconnects forever with an already-consumed id (1008 loop). + res.Headers.Add("Cache-Control", "no-store"); + res.Headers.Add("Pragma", "no-cache"); + this.LogVerbose("Join Room Request with token: {token}", token); byte[] body; @@ -1207,8 +1230,7 @@ namespace PepperDash.Essentials.WebSocketServer this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now); // Construct WebSocket URL with clientId query parameter - var wsProtocol = "ws"; - var wsUrl = $"{wsProtocol}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}"; + var wsUrl = $"{WsScheme}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}"; // Construct the response object JoinResponse jRes = new JoinResponse @@ -1220,7 +1242,8 @@ namespace PepperDash.Essentials.WebSocketServer Config = _parent.GetConfigWithPluginVersion(), CodeExpires = new DateTime().AddYears(1), UserCode = bridge.UserCode, - UserAppUrl = string.Format("http://{0}:{1}/mc/app", + UserAppUrl = string.Format("{0}://{1}:{2}/mc/app", + HttpScheme, CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), Port), WebSocketUrl = wsUrl, @@ -1245,6 +1268,8 @@ namespace PepperDash.Essentials.WebSocketServer { res.StatusCode = 200; res.ContentType = "application/json"; + res.Headers.Add("Cache-Control", "no-store"); + res.Headers.Add("Pragma", "no-cache"); var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion }; var message = JsonConvert.SerializeObject(version); this.LogVerbose("{message}", message); diff --git a/src/PepperDash.Essentials/AssetLoader.cs b/src/PepperDash.Essentials/AssetLoader.cs new file mode 100644 index 00000000..d8b9f5c1 --- /dev/null +++ b/src/PepperDash.Essentials/AssetLoader.cs @@ -0,0 +1,257 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using PepperDash.Core; +using Serilog.Events; + +namespace PepperDash.Essentials +{ + /// + /// Handles extracting embedded asset bundles and moving configuration files from the + /// application directory to the program file-path prefix at startup. + /// Implemented using System.IO types so it can run (and be tested) outside + /// of a Crestron runtime environment. + /// + internal static class AssetLoader + { + /// + /// Scans for well-known zip bundles and + /// JSON configuration files and deploys them to . + /// + /// + /// The directory to scan (typically the Crestron application root). + /// + /// + /// The program's runtime working directory (e.g. /nvram/program1/). + /// + internal static void Load(string applicationDirectoryPath, string filePathPrefix) + { + var applicationDirectory = new DirectoryInfo(applicationDirectoryPath); + Debug.LogMessage(LogEventLevel.Information, + "Searching: {applicationDirectory:l} for embedded assets - {Destination}", + applicationDirectory.FullName, filePathPrefix); + + ExtractAssetsZip(applicationDirectory, filePathPrefix); + ExtractHtmlAssetsZip(applicationDirectory, filePathPrefix); + ExtractDevToolsZip(applicationDirectory, filePathPrefix); + MoveConfigurationFile(applicationDirectory, filePathPrefix); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static void ExtractAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var zipFiles = applicationDirectory.GetFiles("assets*.zip"); + + if (zipFiles.Length > 1) + throw new Exception("Multiple assets zip files found. Cannot continue."); + + if (zipFiles.Length == 1) + { + var zipFile = zipFiles[0]; + var assetsRoot = Path.GetFullPath(filePathPrefix); + if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + assetsRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName); + + using (var archive = ZipFile.OpenRead(zipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(filePathPrefix, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (Directory.Exists(destinationPath)) + Directory.Delete(destinationPath, recursive: true); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in zipFiles) + File.Delete(file.FullName); + } + + private static void ExtractHtmlAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip"); + + if (htmlZipFiles.Length > 1) + throw new Exception( + "Multiple htmlassets zip files found in application directory. " + + "Please ensure only one htmlassets*.zip file is present and retry."); + + if (htmlZipFiles.Length == 1) + { + var htmlZipFile = htmlZipFiles[0]; + var programDir = new DirectoryInfo( + filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; + if (rootDir == null) + throw new Exception( + $"Unable to determine root directory for html extraction. Current path: {filePathPrefix}"); + + var htmlDir = Path.Combine(rootDir.FullName, "html"); + var htmlRoot = Path.GetFullPath(htmlDir); + if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + htmlRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName); + + using (var archive = ZipFile.OpenRead(htmlZipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(htmlDir, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + + var parentDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in htmlZipFiles) + File.Delete(file.FullName); + } + + private static void ExtractDevToolsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var devToolsZipFiles = applicationDirectory.GetFiles("essentials-devtools*.zip"); + + if (devToolsZipFiles.Length > 1) + throw new Exception( + "Multiple essentials-devtools zip files found in application directory. " + + "Please ensure only one essentials-devtools*.zip file is present and retry."); + + if (devToolsZipFiles.Length == 1) + { + var devToolsZipFile = devToolsZipFiles[0]; + var programDir = new DirectoryInfo( + filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; + if (rootDir == null) + throw new Exception( + $"Unable to determine root directory for debug html extraction. Current path: {filePathPrefix}"); + + var debugDir = Path.Combine(rootDir.FullName, "html", "debug"); + var debugRoot = Path.GetFullPath(debugDir); + if (!debugRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !debugRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + debugRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found essentials-devtools zip file: {zipFile:l}... Unzipping to {Destination}...", + devToolsZipFile.FullName, debugDir); + + using (var archive = ZipFile.OpenRead(devToolsZipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(debugDir, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(debugRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + + var parentDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in devToolsZipFiles) + File.Delete(file.FullName); + } + + private static void MoveConfigurationFile(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json"); + + if (jsonFiles.Length > 1) + { + Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", + jsonFiles.Select(f => f.FullName).ToArray()); + throw new Exception("Multiple configuration files found. Cannot continue."); + } + + if (jsonFiles.Length == 1) + { + var jsonFile = jsonFiles[0]; + var finalPath = Path.Combine(filePathPrefix, jsonFile.Name); + Debug.LogMessage(LogEventLevel.Information, + "Found configuration file: {jsonFile:l}... Moving to: {Destination}", + jsonFile.FullName, finalPath); + + if (File.Exists(finalPath)) + { + Debug.LogMessage(LogEventLevel.Information, + "Removing existing configuration file: {Destination}", finalPath); + File.Delete(finalPath); + } + + jsonFile.MoveTo(finalPath); + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index ef19c955..47de4d3e 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -1,5 +1,4 @@ using System; -using System.IO.Compression; using System.Linq; using System.Reflection; using Crestron.SimplSharp; @@ -28,6 +27,8 @@ namespace PepperDash.Essentials private CEvent _initializeEvent; private const long StartupTime = 500; + // private const string minimumFirmwareVersion = "2.8006.00110"; + /// /// Initializes a new instance of the ControlSystem class /// @@ -47,6 +48,24 @@ namespace PepperDash.Essentials /// public override void InitializeSystem() { + + // Get FW version and stop if it's too low to run this version of Essentials. Must be greater than v2.8006.00110 + // var fwVersion = InitialParametersClass.FirmwareVersion; + + // Debug.LogInformation("Control System Hardware Version: {fwVersion}", fwVersion); + + // // split the version into parts and compare against minimumFirmwareVersion + // var versionParts = fwVersion.Split('.').Select(int.Parse).ToArray(); + // var minParts = minimumFirmwareVersion.Split('.').Select(int.Parse).ToArray(); + // if (versionParts.Length < minParts.Length + // || versionParts[0] < minParts[0] + // || (versionParts[0] == minParts[0] && versionParts[1] < minParts[1]) + // || (versionParts[0] == minParts[0] && versionParts[1] == minParts[1] && versionParts[2] <= minParts[2])) + // { + // Debug.LogFatal("Firmware version {fwVersion} is too low to run this version of Essentials. Please upgrade to greater than v{minimumFirmwareVersion}.", fwVersion, minimumFirmwareVersion); + // return; + // } + // If the control system is a DMPS type, we need to wait to exit this method until all devices have had time to activate // to allow any HD-BaseT DM endpoints to register first. bool preventInitializationComplete = Global.ControlSystemIsDmpsType; @@ -92,12 +111,16 @@ namespace PepperDash.Essentials CrestronConsole.AddNewConsoleCommand(s => Debug.LogMessage(LogEventLevel.Information, "CONSOLE MESSAGE: {0}", s), "appdebugmessage", "Writes message to log", ConsoleAccessLevelEnum.AccessOperator); - CrestronConsole.AddNewConsoleCommand(s => - { - foreach (var tl in TieLineCollection.Default) - CrestronConsole.ConsoleCommandResponse(" {0}{1}", tl, CrestronEnvironment.NewLine); - }, - "listtielines", "Prints out all tie lines", ConsoleAccessLevelEnum.AccessOperator); + CrestronConsole.AddNewConsoleCommand(ListTieLines, + "listtielines", "Prints out all tie lines. Usage: listtielines [signaltype]", ConsoleAccessLevelEnum.AccessOperator); + + CrestronConsole.AddNewConsoleCommand(VisualizeRoutes, "visualizeroutes", + "Visualizes routes by signal type", + ConsoleAccessLevelEnum.AccessOperator); + + CrestronConsole.AddNewConsoleCommand(VisualizeCurrentRoutes, "visualizecurrentroutes", + "Visualizes current active routes from DefaultCollection", + ConsoleAccessLevelEnum.AccessOperator); CrestronConsole.AddNewConsoleCommand(s => { @@ -107,14 +130,8 @@ namespace PepperDash.Essentials (ConfigReader.ConfigObject, Newtonsoft.Json.Formatting.Indented).Replace(Environment.NewLine, "\r\n")); }, "showconfig", "Shows the current running merged config", ConsoleAccessLevelEnum.AccessOperator); - CrestronConsole.AddNewConsoleCommand(s => - CrestronConsole.ConsoleCommandResponse( - "This system can be found at the following URLs:{2}" + - "System URL: {0}{2}" + - "Template URL: {1}{2}", - ConfigReader.ConfigObject.SystemUrl, - ConfigReader.ConfigObject.TemplateUrl, - CrestronEnvironment.NewLine), + CrestronConsole.AddNewConsoleCommand( + PrintPortalInfo, "portalinfo", "Shows portal URLS from configuration", ConsoleAccessLevelEnum.AccessOperator); @@ -137,6 +154,29 @@ namespace PepperDash.Essentials } } + private void PrintPortalInfo(string args) + { + if(ConfigReader.ConfigObject == null) + { + CrestronConsole.ConsoleCommandResponse("No configuration loaded. Cannot show portal URLs."); + return; + } + + if (string.IsNullOrEmpty(ConfigReader.ConfigObject.SystemUrl) && string.IsNullOrEmpty(ConfigReader.ConfigObject.TemplateUrl)) + { + CrestronConsole.ConsoleCommandResponse("No portal URLs defined in config."); + return; + } + + CrestronConsole.ConsoleCommandResponse( + "This system can be found at the following URLs:{2}" + + "System URL: {0}{2}" + + "Template URL: {1}{2}", + ConfigReader.ConfigObject?.SystemUrl, + ConfigReader.ConfigObject?.TemplateUrl, + CrestronEnvironment.NewLine); + } + /// /// DeterminePlatform method /// @@ -234,13 +274,8 @@ namespace PepperDash.Essentials PluginLoader.AddProgramAssemblies(); _ = new Core.DeviceFactory(); - // _ = new Devices.Common.DeviceFactory(); - // _ = new DeviceFactory(); - // _ = new ProcessorExtensionDeviceFactory(); - // _ = new MobileControlFactory(); - - LoadAssets(); + LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix); Debug.LogMessage(LogEventLevel.Information, "Starting Essentials load from configuration"); @@ -251,10 +286,9 @@ namespace PepperDash.Essentials PluginLoader.LoadPlugins(); Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config..."); - if (!ConfigReader.LoadConfig2()) + if (!ConfigReader.LoadConfig2() || ConfigReader.ConfigObject == null) { - Debug.LogMessage(LogEventLevel.Information, "Essentials Load complete with errors"); - return; + Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file."); } Load(); @@ -376,6 +410,12 @@ namespace PepperDash.Essentials new Core.Monitoring.SystemMonitorController("systemMonitor")); } + if (ConfigReader.ConfigObject is null) + { + Debug.LogMessage(LogEventLevel.Warning, "LoadDevices: ConfigObject is null. Cannot load devices."); + return; + } + foreach (var devConf in ConfigReader.ConfigObject.Devices) { IKeyed newDev = null; @@ -429,7 +469,7 @@ namespace PepperDash.Essentials var tlc = TieLineCollection.Default; - if (ConfigReader.ConfigObject.TieLines == null) + if (ConfigReader.ConfigObject?.TieLines == null) { return; } @@ -443,6 +483,282 @@ namespace PepperDash.Essentials Debug.LogMessage(LogEventLevel.Information, "All Tie Lines Loaded."); + Extensions.MapDestinationsToSources(); + + Debug.LogMessage(LogEventLevel.Information, "All Routes Mapped."); + } + + + + /// + /// Visualizes routes in a tree format for better understanding of signal paths + /// + private void ListTieLines(string args) + { + try + { + if (!string.IsNullOrEmpty(args) && args.Contains("?")) + { + CrestronConsole.ConsoleCommandResponse("Usage: listtielines [signaltype]\r\n"); + CrestronConsole.ConsoleCommandResponse("Signal types: Audio, Video, SecondaryAudio, AudioVideo, UsbInput, UsbOutput\r\n"); + return; + } + + eRoutingSignalType? signalTypeFilter = null; + if (!string.IsNullOrEmpty(args)) + { + eRoutingSignalType parsedType; + if (Enum.TryParse(args.Trim(), true, out parsedType)) + { + signalTypeFilter = parsedType; + } + else + { + CrestronConsole.ConsoleCommandResponse("Invalid signal type: {0}\r\n", args.Trim()); + CrestronConsole.ConsoleCommandResponse("Valid types: Audio, Video, SecondaryAudio, AudioVideo, UsbInput, UsbOutput\r\n"); + return; + } + } + + var tielines = signalTypeFilter.HasValue + ? TieLineCollection.Default.Where(tl => tl.Type.HasFlag(signalTypeFilter.Value)) + : TieLineCollection.Default; + + var count = 0; + foreach (var tl in tielines) + { + CrestronConsole.ConsoleCommandResponse(" {0}{1}", tl, CrestronEnvironment.NewLine); + count++; + } + + CrestronConsole.ConsoleCommandResponse("\r\nTotal: {0} tieline{1}{2}", count, count == 1 ? "" : "s", CrestronEnvironment.NewLine); + } + catch (Exception ex) + { + CrestronConsole.ConsoleCommandResponse("Error listing tielines: {0}\r\n", ex.Message); + } + } + + private void VisualizeRoutes(string args) + { + try + { + if (!string.IsNullOrEmpty(args) && args.Contains("?")) + { + CrestronConsole.ConsoleCommandResponse("Usage: visualizeroutes [signaltype] [-s source] [-d destination]\r\n"); + CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n"); + CrestronConsole.ConsoleCommandResponse(" -s: Filter by source key (partial match)\r\n"); + CrestronConsole.ConsoleCommandResponse(" -d: Filter by destination key (partial match)\r\n"); + return; + } + + ParseRouteFilters(args, out eRoutingSignalType? signalTypeFilter, out string sourceFilter, out string destFilter); + + CrestronConsole.ConsoleCommandResponse("\r\n+===========================================================================+\r\n"); + CrestronConsole.ConsoleCommandResponse("| ROUTE VISUALIZATION |\r\n"); + CrestronConsole.ConsoleCommandResponse("+===========================================================================+\r\n\r\n"); + + foreach (var descriptorCollection in Extensions.RouteDescriptors.Where(kv => kv.Value.Descriptors.Count() > 0)) + { + // Filter by signal type if specified + if (signalTypeFilter.HasValue && descriptorCollection.Key != signalTypeFilter.Value) + continue; + + CrestronConsole.ConsoleCommandResponse("\r\n+--- Signal Type: {0} ({1} routes) ---\r\n", + descriptorCollection.Key, + descriptorCollection.Value.Descriptors.Count()); + + foreach (var descriptor in descriptorCollection.Value.Descriptors) + { + // Filter by source/dest if specified + if (sourceFilter != null && !descriptor.Source.Key.ToLower().Contains(sourceFilter)) + continue; + if (destFilter != null && !descriptor.Destination.Key.ToLower().Contains(destFilter)) + continue; + + VisualizeRouteDescriptor(descriptor); + } + } + + CrestronConsole.ConsoleCommandResponse("\r\n"); + } + catch (Exception ex) + { + CrestronConsole.ConsoleCommandResponse("Error visualizing routes: {0}\r\n", ex.Message); + } + } + + private void VisualizeCurrentRoutes(string args) + { + try + { + if (!string.IsNullOrEmpty(args) && args.Contains("?")) + { + CrestronConsole.ConsoleCommandResponse("Usage: visualizecurrentroutes [signaltype] [-s source] [-d destination]\r\n"); + CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n"); + CrestronConsole.ConsoleCommandResponse(" -s: Filter by source key (partial match)\r\n"); + CrestronConsole.ConsoleCommandResponse(" -d: Filter by destination key (partial match)\r\n"); + return; + } + + ParseRouteFilters(args, out eRoutingSignalType? signalTypeFilter, out string sourceFilter, out string destFilter); + + CrestronConsole.ConsoleCommandResponse("\r\n+===========================================================================+\r\n"); + CrestronConsole.ConsoleCommandResponse("| CURRENT ROUTES VISUALIZATION |\r\n"); + CrestronConsole.ConsoleCommandResponse("+===========================================================================+\r\n\r\n"); + + var hasRoutes = false; + + // Get all descriptors from DefaultCollection + var allDescriptors = RouteDescriptorCollection.DefaultCollection.Descriptors; + + // Group by signal type + var groupedByType = allDescriptors.GroupBy(d => d.SignalType); + + foreach (var group in groupedByType) + { + var signalType = group.Key; + + // Filter by signal type if specified + if (signalTypeFilter.HasValue && signalType != signalTypeFilter.Value) + continue; + + var filteredDescriptors = group.Where(d => + { + if (sourceFilter != null && !d.Source.Key.ToLower().Contains(sourceFilter)) + return false; + if (destFilter != null && !d.Destination.Key.ToLower().Contains(destFilter)) + return false; + return true; + }).ToList(); + + if (filteredDescriptors.Count == 0) + continue; + + hasRoutes = true; + CrestronConsole.ConsoleCommandResponse("\r\n+--- Signal Type: {0} ({1} routes) ---\r\n", + signalType, + filteredDescriptors.Count); + + foreach (var descriptor in filteredDescriptors) + { + VisualizeRouteDescriptor(descriptor); + } + } + + if (!hasRoutes) + { + CrestronConsole.ConsoleCommandResponse("\r\nNo active routes found in current state.\r\n"); + } + + CrestronConsole.ConsoleCommandResponse("\r\n"); + } + catch (Exception ex) + { + CrestronConsole.ConsoleCommandResponse("Error visualizing current state: {0}\r\n", ex.Message); + } + } + + /// + /// Parses route filter arguments from command line + /// + /// Command line arguments + /// Parsed signal type filter (if any) + /// Parsed source filter (if any) + /// Parsed destination filter (if any) + private void ParseRouteFilters(string args, out eRoutingSignalType? signalTypeFilter, out string sourceFilter, out string destFilter) + { + signalTypeFilter = null; + sourceFilter = null; + destFilter = null; + + if (string.IsNullOrEmpty(args)) + return; + + var parts = args.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < parts.Length; i++) + { + var part = parts[i]; + + // Check for flags + if (part == "-s" && i + 1 < parts.Length) + { + sourceFilter = parts[++i].ToLower(); + } + else if (part == "-d" && i + 1 < parts.Length) + { + destFilter = parts[++i].ToLower(); + } + // Try to parse as signal type if not a flag and no signal type set yet + else if (!part.StartsWith("-") && !signalTypeFilter.HasValue) + { + if (Enum.TryParse(part, true, out eRoutingSignalType parsedType)) + { + signalTypeFilter = parsedType; + } + } + } + } + + /// + /// Visualizes a single route descriptor in a tree format + /// + private void VisualizeRouteDescriptor(RouteDescriptor descriptor) + { + CrestronConsole.ConsoleCommandResponse("|\r\n"); + CrestronConsole.ConsoleCommandResponse("|-- {0} --> {1}\r\n", + descriptor.Source.Key, + descriptor.Destination.Key); + + if (descriptor.Routes == null || descriptor.Routes.Count == 0) + { + CrestronConsole.ConsoleCommandResponse("| +-- (No switching steps)\r\n"); + return; + } + + for (int i = 0; i < descriptor.Routes.Count; i++) + { + var route = descriptor.Routes[i]; + var isLast = i == descriptor.Routes.Count - 1; + var prefix = isLast ? "+" : "|"; + var continuation = isLast ? " " : "|"; + + if (route.SwitchingDevice != null) + { + CrestronConsole.ConsoleCommandResponse("| {0}-- [{1}] {2}\r\n", + prefix, + route.SwitchingDevice.Key, + GetSwitchDescription(route)); + + // Add visual connection line for non-last items + if (!isLast) + CrestronConsole.ConsoleCommandResponse("| {0} |\r\n", continuation); + } + else + { + CrestronConsole.ConsoleCommandResponse("| {0}-- {1}\r\n", prefix, route.ToString()); + } + } + } + + /// + /// Gets a readable description of the switching operation + /// + private string GetSwitchDescription(RouteSwitchDescriptor route) + { + if (route.OutputPort != null && route.InputPort != null) + { + return string.Format("{0} -> {1}", route.OutputPort.Key, route.InputPort.Key); + } + else if (route.InputPort != null) + { + return string.Format("-> {0}", route.InputPort.Key); + } + else + { + return "(passthrough)"; + } } /// @@ -450,7 +766,7 @@ namespace PepperDash.Essentials /// public void LoadRooms() { - if (ConfigReader.ConfigObject.Rooms == null) + if (ConfigReader.ConfigObject?.Rooms == null) { Debug.LogMessage(LogEventLevel.Information, "Notice: Configuration contains no rooms - Is this intentional? This may be a valid configuration."); return; @@ -485,15 +801,16 @@ namespace PepperDash.Essentials /// /// Fires up a logo server if not already running /// + [Obsolete("Logo server is no longer supported and will be removed in a future release.")] void LoadLogoServer() { - if (ConfigReader.ConfigObject.Rooms == null) + if (ConfigReader.ConfigObject?.Rooms == null) { Debug.LogMessage(LogEventLevel.Information, "No rooms configured. Bypassing Logo server startup."); return; } - if ( + if (ConfigReader.ConfigObject?.Rooms == null || !ConfigReader.ConfigObject.Rooms.Any( CheckRoomConfig)) { @@ -544,142 +861,8 @@ namespace PepperDash.Essentials } } - private static void LoadAssets() - { - var applicationDirectory = new DirectoryInfo(Global.ApplicationDirectoryPathPrefix); - Debug.LogMessage(LogEventLevel.Information, "Searching: {applicationDirectory:l} for embedded assets - {Destination}", applicationDirectory.FullName, Global.FilePathPrefix); + internal static void LoadAssets(string applicationDirectoryPath, string filePathPrefix) => + AssetLoader.Load(applicationDirectoryPath, filePathPrefix); - var zipFiles = applicationDirectory.GetFiles("assets*.zip"); - - if (zipFiles.Length > 1) - { - throw new Exception("Multiple assets zip files found. Cannot continue."); - } - - if (zipFiles.Length == 1) - { - var zipFile = zipFiles[0]; - var assetsRoot = System.IO.Path.GetFullPath(Global.FilePathPrefix); - if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) - { - assetsRoot += Path.DirectorySeparatorChar; - } - Debug.LogMessage(LogEventLevel.Information, "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName); - using (var archive = ZipFile.OpenRead(zipFile.FullName)) - { - foreach (var entry in archive.Entries) - { - var destinationPath = Path.Combine(Global.FilePathPrefix, entry.FullName); - var fullDest = System.IO.Path.GetFullPath(destinationPath); - if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory."); - - if (string.IsNullOrEmpty(entry.Name)) - { - Directory.CreateDirectory(destinationPath); - continue; - } - - // If a directory exists where a file should go, delete it - if (Directory.Exists(destinationPath)) - Directory.Delete(destinationPath, true); - - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); - entry.ExtractToFile(destinationPath, true); - Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); - } - } - } - - // cleaning up zip files - foreach (var file in zipFiles) - { - File.Delete(file.FullName); - } - - var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip"); - - if (htmlZipFiles.Length > 1) - { - throw new Exception("Multiple htmlassets zip files found in application directory. Please ensure only one htmlassets*.zip file is present and retry."); - } - - - if (htmlZipFiles.Length == 1) - { - var htmlZipFile = htmlZipFiles[0]; - var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - var userOrNvramDir = programDir.Parent; - var rootDir = userOrNvramDir?.Parent; - if (rootDir == null) - { - throw new Exception($"Unable to determine root directory for html extraction. Current path: {Global.FilePathPrefix}"); - } - var htmlDir = Path.Combine(rootDir.FullName, "html"); - var htmlRoot = System.IO.Path.GetFullPath(htmlDir); - if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && - !htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) - { - htmlRoot += Path.DirectorySeparatorChar; - } - Debug.LogMessage(LogEventLevel.Information, "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName); - using (var archive = ZipFile.OpenRead(htmlZipFile.FullName)) - { - foreach (var entry in archive.Entries) - { - var destinationPath = Path.Combine(htmlDir, entry.FullName); - var fullDest = System.IO.Path.GetFullPath(destinationPath); - if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory."); - - if (string.IsNullOrEmpty(entry.Name)) - { - Directory.CreateDirectory(destinationPath); - continue; - } - - // Only delete the file if it exists and is a file, not a directory - if (File.Exists(destinationPath)) - File.Delete(destinationPath); - - var parentDir = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(parentDir)) - Directory.CreateDirectory(parentDir); - - entry.ExtractToFile(destinationPath, true); - Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); - } - } - } - - // cleaning up html zip files - foreach (var file in htmlZipFiles) - { - File.Delete(file.FullName); - } - - var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json"); - - if (jsonFiles.Length > 1) - { - Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", jsonFiles.Select(f => f.FullName).ToArray()); - throw new Exception("Multiple configuration files found. Cannot continue."); - } - - if (jsonFiles.Length == 1) - { - var jsonFile = jsonFiles[0]; - var finalPath = Path.Combine(Global.FilePathPrefix, jsonFile.Name); - Debug.LogMessage(LogEventLevel.Information, "Found configuration file: {jsonFile:l}... Moving to: {Destination}", jsonFile.FullName, finalPath); - - if (File.Exists(finalPath)) - { - Debug.LogMessage(LogEventLevel.Information, "Removing existing configuration file: {Destination}", finalPath); - File.Delete(finalPath); - } - - jsonFile.MoveTo(finalPath); - } - } } } diff --git a/src/PepperDash.Essentials/PepperDash.Essentials.csproj b/src/PepperDash.Essentials/PepperDash.Essentials.csproj index 20a42ffd..cb83bf84 100644 --- a/src/PepperDash.Essentials/PepperDash.Essentials.csproj +++ b/src/PepperDash.Essentials/PepperDash.Essentials.csproj @@ -48,7 +48,7 @@ - +