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 @@
-
+