Compare commits

..

No commits in common. "main" and "v2.29.0-tieline-visualisation.2" have entirely different histories.

60 changed files with 756 additions and 3360 deletions

View file

@ -19,5 +19,4 @@ jobs:
version: ${{ needs.getVersion.outputs.version }} version: ${{ needs.getVersion.outputs.version }}
tag: ${{ needs.getVersion.outputs.tag }} tag: ${{ needs.getVersion.outputs.tag }}
channel: ${{ needs.getVersion.outputs.channel }} channel: ${{ needs.getVersion.outputs.channel }}
bypassPackageCheck: true bypassPackageCheck: true
devToolsVersion: ${{ vars.ESSENTIALSDEVTOOLSVERSION }}

View file

@ -183,12 +183,11 @@ namespace PepperDash.Core
Cresnet = 8, Cresnet = 8,
Cec = 9, Cec = 9,
Udp = 10, 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```, ```UdpClient```, 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```, and ```Udp```.
##### ComParams ##### ComParams
@ -288,7 +287,7 @@ This property maps to the number of the port on the device you have mapped the r
##### TcpSshParams ##### TcpSshParams
A ```Ssh```, ```TcpIp```, ```UdpClient```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket. A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket.
```Json ```Json
{ {
@ -305,7 +304,7 @@ A ```Ssh```, ```TcpIp```, ```UdpClient```, or ```Udp``` device requires a ```tcp
**```address```** **```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. 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.
**```port```** **```port```**

View file

@ -1,11 +1,11 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.42.1-local</Version> <Version>2.29.0-local</Version>
<InformationalVersion>$(Version)</InformationalVersion> <InformationalVersion>$(Version)</InformationalVersion>
<Authors>PepperDash Technology</Authors> <Authors>PepperDash Technology</Authors>
<Company>PepperDash Technology</Company> <Company>PepperDash Technology</Company>
<Product>PepperDash Essentials</Product> <Product>PepperDash Essentials</Product>
<Copyright>Copyright © 2026</Copyright> <Copyright>Copyright © 2025</Copyright>
<RepositoryUrl>https://github.com/PepperDash/Essentials</RepositoryUrl> <RepositoryUrl>https://github.com/PepperDash/Essentials</RepositoryUrl>
<RepositoryType>git</RepositoryType> <RepositoryType>git</RepositoryType>
<PackageTags>Crestron; 4series</PackageTags> <PackageTags>Crestron; 4series</PackageTags>
@ -20,7 +20,4 @@
<None Include="..\..\LICENSE.md" Pack="true" PackagePath=""/> <None Include="..\..\LICENSE.md" Pack="true" PackagePath=""/>
<None Include="..\..\README.md" Pack="true" PackagePath=""/> <None Include="..\..\README.md" Pack="true" PackagePath=""/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<AssemblyMetadata Include="PackageId" Value="$(PackageId)" />
</ItemGroup>
</Project> </Project>

View file

@ -151,8 +151,6 @@ namespace PepperDash.Core
// Thread-safety lock for state changes // Thread-safety lock for state changes
private readonly object _stateLock = new object(); private readonly object _stateLock = new object();
private volatile bool _isProgramStopping;
private bool disconnectLogged = false; private bool disconnectLogged = false;
/// <summary> /// <summary>
@ -209,9 +207,11 @@ namespace PepperDash.Core
{ {
if (programEventType == eProgramStatusEventType.Stopping) if (programEventType == eProgramStatusEventType.Stopping)
{ {
_isProgramStopping = true; if (client != null)
this.LogDebug("Program stopping. Closing connection"); {
Disconnect(); this.LogDebug("Program stopping. Closing connection");
Disconnect();
}
} }
} }
@ -228,12 +228,6 @@ namespace PepperDash.Core
return; return;
} }
if (_isProgramStopping)
{
this.LogDebug("Skipping connect because program is stopping");
return;
}
ConnectEnabled = true; ConnectEnabled = true;
try try
@ -293,7 +287,13 @@ namespace PepperDash.Core
} }
catch (SshConnectionException e) catch (SshConnectionException e)
{ {
var ie = e.InnerException; // The details are inside, when present - remote can close the connection with no inner exception at all var ie = e.InnerException; // The details are inside!!
if (ie is SocketException)
{
this.LogError("CONNECTION failure: Cannot reach host");
this.LogVerbose(ie, "Exception details: ");
}
if (ie is System.Net.Sockets.SocketException socketException) if (ie is System.Net.Sockets.SocketException socketException)
{ {
@ -301,20 +301,20 @@ namespace PepperDash.Core
Hostname, Port); Hostname, Port);
this.LogVerbose(socketException, "SocketException details: "); this.LogVerbose(socketException, "SocketException details: ");
} }
else if (ie is SshAuthenticationException) if (ie is SshAuthenticationException)
{ {
this.LogError("Authentication failure for username {userName}", Username); this.LogError("Authentication failure for username {userName}", Username);
this.LogVerbose(ie, "AuthenticationException details: "); this.LogVerbose(ie, "AuthenticationException details: ");
} }
else else
{ {
this.LogError("Error on connect: {error}", ie?.Message ?? e.Message); this.LogError("Error on connect: {error}", ie.Message);
this.LogVerbose(ie ?? e, "Exception details: "); this.LogVerbose(ie, "Exception details: ");
} }
disconnectLogged = true; disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) if (AutoReconnect)
{ {
this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -326,7 +326,7 @@ namespace PepperDash.Core
disconnectLogged = true; disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) if (AutoReconnect)
{ {
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -338,7 +338,7 @@ namespace PepperDash.Core
this.LogVerbose(e, "Exception details: "); this.LogVerbose(e, "Exception details: ");
disconnectLogged = true; disconnectLogged = true;
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) if (AutoReconnect)
{ {
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -473,7 +473,7 @@ namespace PepperDash.Core
{ {
connectLock.Release(); connectLock.Release();
} }
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) if (AutoReconnect && ConnectEnabled)
{ {
this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs);
StartReconnectTimer(); StartReconnectTimer();
@ -516,10 +516,7 @@ namespace PepperDash.Core
this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim()); this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim());
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) StartReconnectTimer();
{
StartReconnectTimer();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -552,10 +549,7 @@ namespace PepperDash.Core
this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes)); this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes));
KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED);
if (AutoReconnect && ConnectEnabled && !_isProgramStopping) StartReconnectTimer();
{
StartReconnectTimer();
}
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -1,463 +0,0 @@
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
{
/// <summary>
/// A class to handle basic UDP communications to a remote endpoint
/// </summary>
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;
/// <summary>
/// Object to enable stream debugging
/// </summary>
public CommunicationStreamDebugging StreamDebugging { get; private set; }
/// <summary>
/// Fires when data is received from the remote endpoint and returns it as a byte array
/// </summary>
public event EventHandler<GenericCommMethodReceiveBytesArgs> BytesReceived;
/// <summary>
/// Fires when data is received from the remote endpoint and returns it as text
/// </summary>
public event EventHandler<GenericCommMethodReceiveTextArgs> TextReceived;
/// <summary>
/// Fires when the socket status changes
/// </summary>
public event EventHandler<GenericSocketStatusChageEventArgs> ConnectionChange;
/// <summary>
/// Address of remote endpoint
/// </summary>
public string Hostname { get; set; }
/// <summary>
/// Port on remote endpoint
/// </summary>
public int Port { get; set; }
/// <summary>
/// Another S+ helper because large port numbers can be treated as signed ints
/// </summary>
public ushort UPort
{
get { return Convert.ToUInt16(Port); }
set { Port = Convert.ToInt32(value); }
}
/// <summary>
/// Defaults to 2000
/// </summary>
public int BufferSize { get; set; }
/// <summary>
/// True when the local socket is created and associated with the configured remote endpoint
/// </summary>
public bool IsConnected
{
get { return ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; }
}
/// <summary>
/// S+ helper for IsConnected
/// </summary>
public ushort UIsConnected
{
get { return (ushort)(IsConnected ? 1 : 0); }
}
/// <summary>
/// The current socket status of the client
/// </summary>
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));
}
}
/// <summary>
/// Ushort representation of client status
/// </summary>
public ushort UStatus
{
get { return (ushort)ClientStatus; }
}
/// <summary>
/// Gets or sets the AutoReconnect
/// </summary>
public bool AutoReconnect { get; set; }
/// <summary>
/// S+ helper for AutoReconnect
/// </summary>
public ushort UAutoReconnect
{
get { return (ushort)(AutoReconnect ? 1 : 0); }
set { AutoReconnect = value == 1; }
}
/// <summary>
/// Milliseconds to wait before attempting to reconnect. Defaults to 5000
/// </summary>
public int AutoReconnectIntervalMs { get; set; }
/// <summary>
/// Constructor
/// </summary>
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);
}
/// <summary>
/// Constructor for S+
/// </summary>
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);
}
/// <summary>
/// Initialize method
/// </summary>
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();
}
}
/// <summary>
/// Deactivate method
/// </summary>
public override bool Deactivate()
{
Disconnect();
return true;
}
/// <summary>
/// Connect method
/// </summary>
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();
}
}
}
}
/// <summary>
/// Disconnect method
/// </summary>
public void Disconnect()
{
lock (stateLock)
{
connectEnabled = false;
reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite);
CleanupClient();
ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT;
}
}
/// <summary>
/// SendText method
/// </summary>
public void SendText(string text)
{
this.PrintSentText(text);
var bytes = Encoding.GetEncoding(28591).GetBytes(text);
SendBytes(bytes);
}
/// <summary>
/// SendBytes method
/// </summary>
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;
}
}
}
}

View file

@ -18,75 +18,70 @@ namespace PepperDash.Core
/// <summary> /// <summary>
/// RS232/422/485 /// RS232/422/485
/// </summary> /// </summary>
Com = 1, Com,
/// <summary> /// <summary>
/// Crestron IpId (most Crestron ethernet devices) /// Crestron IpId (most Crestron ethernet devices)
/// </summary> /// </summary>
IpId = 2, IpId,
/// <summary> /// <summary>
/// Crestron IpIdTcp (HD-MD series, etc.) /// Crestron IpIdTcp (HD-MD series, etc.)
/// </summary> /// </summary>
IpidTcp = 3, IpidTcp,
/// <summary> /// <summary>
/// Crestron IR control /// Crestron IR control
/// </summary> /// </summary>
IR = 4, IR,
/// <summary> /// <summary>
/// SSH client /// SSH client
/// </summary> /// </summary>
Ssh = 5, Ssh,
/// <summary> /// <summary>
/// TCP/IP client /// TCP/IP client
/// </summary> /// </summary>
Tcpip = 6, Tcpip,
/// <summary> /// <summary>
/// Telnet /// Telnet
/// </summary> /// </summary>
Telnet = 7, Telnet,
/// <summary> /// <summary>
/// Crestnet device /// Crestnet device
/// </summary> /// </summary>
Cresnet = 8, Cresnet,
/// <summary> /// <summary>
/// CEC Control, via a DM HDMI port /// CEC Control, via a DM HDMI port
/// </summary> /// </summary>
Cec = 9, Cec,
/// <summary> /// <summary>
/// UDP Server /// UDP Server
/// </summary> /// </summary>
Udp = 10, Udp,
/// <summary> /// <summary>
/// HTTP client /// HTTP client
/// </summary> /// </summary>
Http = 11, Http,
/// <summary> /// <summary>
/// HTTPS client /// HTTPS client
/// </summary> /// </summary>
Https = 12, Https,
/// <summary> /// <summary>
/// Websocket client /// Websocket client
/// </summary> /// </summary>
Ws = 13, Ws,
/// <summary> /// <summary>
/// Secure Websocket client /// Secure Websocket client
/// </summary> /// </summary>
Wss = 14, Wss,
/// <summary> /// <summary>
/// Secure TCP/IP /// Secure TCP/IP
/// </summary> /// </summary>
SecureTcpIp = 15, SecureTcpIp,
/// <summary> /// <summary>
/// Used when comms needs to be handled in SIMPL and bridged opposite the normal direction /// Used when comms needs to be handled in SIMPL and bridged opposite the normal direction
/// </summary> /// </summary>
ComBridge = 16, ComBridge,
/// <summary> /// <summary>
/// InfinetEX control /// InfinetEX control
/// </summary> /// </summary>
InfinetEx = 17, InfinetEx
/// <summary>
/// UDP client
/// </summary>
UdpClient = 18,
} }
} }

View file

@ -5,9 +5,9 @@ using Serilog.Events;
namespace PepperDash.Core namespace PepperDash.Core
{ {
//********************************************************************************************************* //*********************************************************************************************************
/// <summary> /// <summary>
/// Represents a Device /// Represents a Device
/// </summary> /// </summary>
public class Device : IKeyName public class Device : IKeyName
{ {
@ -15,15 +15,25 @@ namespace PepperDash.Core
/// Unique Key /// Unique Key
/// </summary> /// </summary>
public string Key { get; protected set; } public string Key { get; protected set; }
/// <summary> /// <summary>
/// Gets or sets the Name /// Gets or sets the Name
/// </summary> /// </summary>
public string Name { get; protected set; } public string Name { get; protected set; }
/// <summary> /// <summary>
/// ///
/// </summary> /// </summary>
public bool Enabled { get; protected set; } public bool Enabled { get; protected set; }
/// <summary>
/// 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.
/// </summary>
//public DeviceConfig Config { get; private set; }
/// <summary>
/// Helper method to check if Config exists
/// </summary>
//public bool HasConfig { get { return Config != null; } }
List<Action> _PreActivationActions; List<Action> _PreActivationActions;
List<Action> _PostActivationActions; List<Action> _PostActivationActions;
@ -76,9 +86,9 @@ namespace PepperDash.Core
/// Adds a post activation action /// Adds a post activation action
/// </summary> /// </summary>
/// <param name="act"></param> /// <param name="act"></param>
/// <summary> /// <summary>
/// AddPostActivationAction method /// AddPostActivationAction method
/// </summary> /// </summary>
public void AddPostActivationAction(Action act) public void AddPostActivationAction(Action act)
{ {
if (_PostActivationActions == null) if (_PostActivationActions == null)
@ -86,9 +96,9 @@ namespace PepperDash.Core
_PostActivationActions.Add(act); _PostActivationActions.Add(act);
} }
/// <summary> /// <summary>
/// PreActivate method /// PreActivate method
/// </summary> /// </summary>
public void PreActivate() public void PreActivate()
{ {
if (_PreActivationActions != null) if (_PreActivationActions != null)
@ -105,9 +115,9 @@ namespace PepperDash.Core
}); });
} }
/// <summary> /// <summary>
/// Activate method /// Activate method
/// </summary> /// </summary>
public bool Activate() public bool Activate()
{ {
//if (_PreActivationActions != null) //if (_PreActivationActions != null)
@ -118,9 +128,9 @@ namespace PepperDash.Core
return result; return result;
} }
/// <summary> /// <summary>
/// PostActivate method /// PostActivate method
/// </summary> /// </summary>
public void PostActivate() public void PostActivate()
{ {
if (_PostActivationActions != null) if (_PostActivationActions != null)
@ -143,9 +153,9 @@ namespace PepperDash.Core
/// do not need to call base.CustomActivate() /// do not need to call base.CustomActivate()
/// </summary> /// </summary>
/// <returns>true if device activated successfully.</returns> /// <returns>true if device activated successfully.</returns>
/// <summary> /// <summary>
/// CustomActivate method /// CustomActivate method
/// </summary> /// </summary>
public virtual bool CustomActivate() { return true; } public virtual bool CustomActivate() { return true; }
/// <summary> /// <summary>
@ -172,15 +182,15 @@ namespace PepperDash.Core
if (o is bool && !(bool)o) a(); if (o is bool && !(bool)o) a();
} }
/// <summary> /// <summary>
/// Returns a string representation of the object, including its key and name. /// Returns a string representation of the object, including its key and name.
/// </summary> /// </summary>
/// <remarks>The returned string is formatted as "{Key} - {Name}". If the <c>Name</c> property is /// <remarks>The returned string is formatted as "{Key} - {Name}". If the <c>Name</c> property is
/// null or empty, "---" is used in place of the name.</remarks> /// null or empty, "---" is used in place of the name.</remarks>
/// <returns>A string that represents the object, containing the key and name in the format "{Key} - {Name}".</returns> /// <returns>A string that represents the object, containing the key and name in the format "{Key} - {Name}".</returns>
/// <summary> /// <summary>
/// ToString method /// ToString method
/// </summary> /// </summary>
public override string ToString() public override string ToString()
{ {
return string.Format("{0} - {1}", Key, string.IsNullOrEmpty(Name) ? "---" : Name); return string.Format("{0} - {1}", Key, string.IsNullOrEmpty(Name) ? "---" : Name);

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net;
using System.Reflection; using System.Reflection;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Crestron.SimplSharp; using Crestron.SimplSharp;

View file

@ -11,7 +11,6 @@ namespace PepperDash.Core
/// <summary> /// <summary>
/// Represents a debugging context /// Represents a debugging context
/// </summary> /// </summary>
[Obsolete("DebugContext is no longer supported and will be removed in a future release.")]
public class DebugContext public class DebugContext
{ {
/// <summary> /// <summary>

View file

@ -1,4 +1,8 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Serilog; using Serilog;
using Serilog.Core; using Serilog.Core;
using Serilog.Events; using Serilog.Events;
@ -8,18 +12,11 @@ using Crestron.SimplSharp;
using WebSocketSharp; using WebSocketSharp;
using System.Security.Authentication; using System.Security.Authentication;
using WebSocketSharp.Net; using WebSocketSharp.Net;
using System.Security.Cryptography.X509Certificates; using X509Certificate2 = System.Security.Cryptography.X509Certificates.X509Certificate2;
using System.IO; using System.IO;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Asn1.X509; 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 Serilog.Formatting;
using Newtonsoft.Json.Linq;
using Serilog.Formatting.Json; using Serilog.Formatting.Json;
namespace PepperDash.Core namespace PepperDash.Core
@ -30,40 +27,26 @@ namespace PepperDash.Core
public class DebugWebsocketSink : ILogEventSink public class DebugWebsocketSink : ILogEventSink
{ {
private HttpServer _httpsServer; private HttpServer _httpsServer;
private string _path = "/debug/join/"; private string _path = "/debug/join/";
private const string _certificateName = "selfCres"; private const string _certificateName = "selfCres";
private const string _certificatePassword = "cres12345"; private const string _certificatePassword = "cres12345";
private static string CertPath => public int Port
$"{Path.DirectorySeparatorChar}user{Path.DirectorySeparatorChar}{_certificateName}.pfx"; { get
{
public int Port if(_httpsServer == null) return 0;
{
get
{
if (_httpsServer == null) return 0;
return _httpsServer.Port; return _httpsServer.Port;
} }
} }
public string Url public string Url
{ {
get get
{ {
if (_httpsServer == null || !_httpsServer.IsListening) return ""; if (_httpsServer == null) return "";
var service = _httpsServer.WebSocketServices[_path]; return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{_httpsServer.WebSocketServices[_path].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}";
} }
} }
@ -71,138 +54,60 @@ namespace PepperDash.Core
/// Gets or sets the IsRunning /// Gets or sets the IsRunning
/// </summary> /// </summary>
public bool IsRunning { get => _httpsServer?.IsListening ?? false; } public bool IsRunning { get => _httpsServer?.IsListening ?? false; }
/// <summary>
/// Gets a value indicating whether there are active WebSocket connections.
/// </summary>
public bool HasActiveConnections
{
get
{
if (_httpsServer == null || !_httpsServer.IsListening) return false;
var service = _httpsServer.WebSocketServices[_path];
if (service == null) return false;
return service.Sessions.Count > 0;
}
}
private readonly ITextFormatter _textFormatter; private readonly ITextFormatter _textFormatter;
/// <summary>
/// Initializes a new instance of the <see cref="DebugWebsocketSink"/> class with the specified text formatter.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="formatProvider">The text formatter used to format log messages. If null, a default JSON formatter is used.</param>
public DebugWebsocketSink(ITextFormatter formatProvider) public DebugWebsocketSink(ITextFormatter formatProvider)
{ {
_textFormatter = formatProvider ?? new JsonFormatter(); _textFormatter = formatProvider ?? new JsonFormatter();
if (!File.Exists(CertPath)) if (!File.Exists($"\\user\\{_certificateName}.pfx"))
CreateCert(); CreateCert(null);
try CrestronEnvironment.ProgramStatusEventHandler += type =>
{ {
CrestronEnvironment.ProgramStatusEventHandler += type => if (type == eProgramStatusEventType.Stopping)
{ {
if (type == eProgramStatusEventType.Stopping) StopServer();
StopServer(); }
}; };
}
catch
{
// CrestronEnvironment is not available in test / dev environments — safe to skip.
}
} }
private static void CreateCert() private void CreateCert(string[] args)
{ {
// 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 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();
var ipAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); 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 hostName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_HOSTNAME, 0);
var domainName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, 0); var domainName = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_DOMAIN_NAME, 0);
CrestronConsole.PrintLine(string.Format("CreateCert: DomainName: {0} | HostName: {1} | {1}.{0}@{2}", domainName, hostName, ipAddress)); //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));
var subjectName = string.Format("CN={0}.{1}", hostName, domainName); 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 });
var fqdn = string.Format("{0}.{1}", hostName, domainName); //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");
var random = new SecureRandom(); //utility.AddCertToStore(certificate, StoreName.My, StoreLocation.LocalMachine);
//Debug.Console(0, "CreateCert Saving Cert to \\user\\");
// Generate RSA 2048 key pair CrestronConsole.PrintLine("CreateCert Saving Cert to \\user\\");
var keyPairGenerator = new RsaKeyPairGenerator(); utility.CertificatePassword = _certificatePassword;
keyPairGenerator.Init(new KeyGenerationParameters(random, 2048)); utility.WriteCertificate(certificate, @"\user\", _certificateName);
var keyPair = keyPairGenerator.GenerateKeyPair(); //Debug.Console(0, "CreateCert Ending CreateCert");
CrestronConsole.PrintLine("CreateCert Ending CreateCert");
// 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) catch (Exception ex)
{ {
CrestronConsole.PrintLine(string.Format("WSS CreateCert Failed: {0}\r\n{1}", ex.Message, ex.StackTrace)); //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));
} }
} }
@ -221,77 +126,14 @@ namespace PepperDash.Core
} }
/// <summary> /// <summary>
/// Starts the WebSocket server on the specified port and configures it with the appropriate certificate. /// StartServerAndSetPort method
/// </summary> /// </summary>
/// <remarks>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.</remarks>
/// <param name="port">The port number on which the WebSocket server will listen. Must be a valid, non-negative port number.</param>
public void StartServerAndSetPort(int port) public void StartServerAndSetPort(int port)
{ {
Debug.LogInformation("Starting Websocket Server on port: {0}", port); Debug.Console(0, "Starting Websocket Server on port: {0}", port);
Start(port, CertPath, _certificatePassword); Start(port, $"\\user\\{_certificateName}.pfx", _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);
}
}
/// <summary>
/// Loads a PKCS#12 file written by BouncyCastle and returns an <see cref="X509Certificate2"/> with
/// private key attached.
/// The PFX is parsed and re-encoded by BouncyCastle (ensuring format compatibility), then passed as
/// raw bytes to <see cref="X509Certificate2"/> so neither <c>RSACryptoServiceProvider</c> nor the
/// <c>EphemeralKeySet</c> flag (unsupported on the Crestron/Mono runtime) is needed.
/// </summary>
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 = "") private void Start(int port, string certPath = "", string certPassword = "")
@ -300,37 +142,66 @@ namespace PepperDash.Core
{ {
_httpsServer = new HttpServer(port, true); _httpsServer = new HttpServer(port, true);
if (!string.IsNullOrWhiteSpace(certPath)) if (!string.IsNullOrWhiteSpace(certPath))
{ {
Debug.LogInformation("Assigning SSL Configuration"); Debug.Console(0, "Assigning SSL Configuration");
_httpsServer.SslConfiguration = new ServerSslConfiguration(new X509Certificate2(certPath, certPassword))
_httpsServer.SslConfiguration.ServerCertificate = LoadOrRecreateCert(certPath, certPassword); {
_httpsServer.SslConfiguration.ClientCertificateRequired = false; ClientCertificateRequired = false,
_httpsServer.SslConfiguration.CheckCertificateRevocation = false; CheckCertificateRevocation = false,
_httpsServer.SslConfiguration.EnabledSslProtocols = SslProtocols.Tls12; EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 | SslProtocols.Tls,
//this is just to test, you might want to actually validate //this is just to test, you might want to actually validate
_httpsServer.SslConfiguration.ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => ClientCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
{ {
Debug.LogInformation("HTTPS ClientCerticateValidation Callback triggered"); Debug.Console(0, "HTTPS ClientCerticateValidation Callback triggered");
return true; return true;
}; }
};
} }
Debug.LogInformation("Adding Debug Client Service"); Debug.Console(0, "Adding Debug Client Service");
_httpsServer.AddWebSocketService<DebugClient>(_path); _httpsServer.AddWebSocketService<DebugClient>(_path);
Debug.LogInformation("Assigning Log Info"); Debug.Console(0, "Assigning Log Info");
_httpsServer.Log.Level = LogLevel.Trace; _httpsServer.Log.Level = LogLevel.Trace;
_httpsServer.Log.Output = WriteWebSocketInternalLog; _httpsServer.Log.Output = (d, s) =>
Debug.LogInformation("Starting"); {
uint level;
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.Start(); _httpsServer.Start();
Debug.LogInformation("Ready"); Debug.Console(0, "Ready");
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogError(ex, "WebSocket Failed to start {0}", ex.Message); Debug.Console(0, "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;
} }
} }
@ -339,68 +210,10 @@ namespace PepperDash.Core
/// </summary> /// </summary>
public void StopServer() public void StopServer()
{ {
Debug.LogInformation("Stopping Websocket Server"); Debug.Console(0, "Stopping Websocket Server");
_httpsServer?.Stop();
try _httpsServer = null;
{
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) ? "<none>" : 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.
}
} }
} }

View file

@ -43,7 +43,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" /> <PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageReference Include="Crestron.SimplSharp.SDK.Library" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.Library" Version="2.21.90" />
<PackageReference Include="Serilog" Version="3.1.1" /> <PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.Expressions" Version="4.0.0" /> <PackageReference Include="Serilog.Expressions" Version="4.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="2.0.0" /> <PackageReference Include="Serilog.Formatting.Compact" Version="2.0.0" />

View file

@ -5,7 +5,6 @@ using Crestron.SimplSharp;
using Crestron.SimplSharp.WebScripting; using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using PepperDash.Core.Logging;
using PepperDash.Core.Web.RequestHandlers; using PepperDash.Core.Web.RequestHandlers;
namespace PepperDash.Core.Web namespace PepperDash.Core.Web
@ -26,26 +25,29 @@ namespace PepperDash.Core.Web
private readonly CCriticalSection _serverLock = new CCriticalSection(); private readonly CCriticalSection _serverLock = new CCriticalSection();
private HttpCwsServer _server; private HttpCwsServer _server;
/// <summary> /// <summary>
/// Gets or sets the Key /// Gets or sets the Key
/// </summary> /// </summary>
public string Key { get; private set; } public string Key { get; private set; }
/// <summary> /// <summary>
/// Gets or sets the Name /// Gets or sets the Name
/// </summary> /// </summary>
public string Name { get; private set; } public string Name { get; private set; }
/// <summary> /// <summary>
/// Gets or sets the BasePath /// Gets or sets the BasePath
/// </summary> /// </summary>
public string BasePath { get; private set; } public string BasePath { get; private set; }
/// <summary> /// <summary>
/// Gets or sets the IsRegistered /// Gets or sets the IsRegistered
/// </summary> /// </summary>
public bool IsRegistered { get; private set; } public bool IsRegistered { get; private set; }
/// <summary>
/// Http request handler
/// </summary>
//public IHttpCwsHandler HttpRequestHandler //public IHttpCwsHandler HttpRequestHandler
//{ //{
// get { return _server.HttpRequestHandler; } // get { return _server.HttpRequestHandler; }
@ -56,6 +58,9 @@ namespace PepperDash.Core.Web
// } // }
//} //}
/// <summary>
/// Received request event handler
/// </summary>
//public event EventHandler<HttpCwsRequestEventArgs> ReceivedRequestEvent //public event EventHandler<HttpCwsRequestEventArgs> ReceivedRequestEvent
//{ //{
// add { _server.ReceivedRequestEvent += new HttpCwsRequestEventHandler(value); } // add { _server.ReceivedRequestEvent += new HttpCwsRequestEventHandler(value); }
@ -86,7 +91,7 @@ namespace PepperDash.Core.Web
/// <param name="key"></param> /// <param name="key"></param>
/// <param name="name"></param> /// <param name="name"></param>
/// <param name="basePath"></param> /// <param name="basePath"></param>
public WebApiServer(string key, string name, string basePath) public WebApiServer(string key, string name, string basePath)
{ {
Key = key; Key = key;
Name = string.IsNullOrEmpty(name) ? DefaultName : name; Name = string.IsNullOrEmpty(name) ? DefaultName : name;
@ -94,8 +99,6 @@ namespace PepperDash.Core.Web
if (_server == null) _server = new HttpCwsServer(BasePath); if (_server == null) _server = new HttpCwsServer(BasePath);
_server.AuthenticateAllRoutes = false;
_server.setProcessName(Key); _server.setProcessName(Key);
_server.HttpRequestHandler = new DefaultRequestHandler(); _server.HttpRequestHandler = new DefaultRequestHandler();
@ -111,7 +114,7 @@ namespace PepperDash.Core.Web
{ {
if (programEventType != eProgramStatusEventType.Stopping) return; if (programEventType != eProgramStatusEventType.Stopping) return;
this.LogInformation("Program stopping. stopping server"); Debug.Console(DebugInfo, this, "Program stopping. stopping server");
Stop(); Stop();
} }
@ -125,18 +128,18 @@ namespace PepperDash.Core.Web
// Re-enable the server if the link comes back up and the status should be connected // Re-enable the server if the link comes back up and the status should be connected
if (ethernetEventArgs.EthernetEventType == eEthernetEventType.LinkUp && IsRegistered) if (ethernetEventArgs.EthernetEventType == eEthernetEventType.LinkUp && IsRegistered)
{ {
this.LogInformation("Ethernet link up. Server is alreedy registered."); Debug.Console(DebugInfo, this, "Ethernet link up. Server is alreedy registered.");
return; return;
} }
this.LogInformation("Ethernet link up. Starting server"); Debug.Console(DebugInfo, this, "Ethernet link up. Starting server");
Start(); Start();
} }
/// <summary> /// <summary>
/// Initialize method /// Initialize method
/// </summary> /// </summary>
public void Initialize(string key, string basePath) public void Initialize(string key, string basePath)
{ {
Key = key; Key = key;
@ -150,7 +153,7 @@ namespace PepperDash.Core.Web
{ {
if (route == null) if (route == null)
{ {
this.LogWarning("Failed to add route, route parameter is null"); Debug.Console(DebugInfo, this, "Failed to add route, route parameter is null");
return; return;
} }
@ -162,36 +165,23 @@ namespace PepperDash.Core.Web
/// Removes a route from CWS /// Removes a route from CWS
/// </summary> /// </summary>
/// <param name="route"></param> /// <param name="route"></param>
/// <summary>
/// RemoveRoute method
/// </summary>
public void RemoveRoute(HttpCwsRoute route) public void RemoveRoute(HttpCwsRoute route)
{ {
if (route == null) if (route == null)
{ {
this.LogWarning("Failed to remove route, route parameter is null"); Debug.Console(DebugInfo, this, "Failed to remote route, orute parameter is null");
return; return;
} }
_server.Routes.Remove(route); _server.Routes.Remove(route);
} }
/// <summary> /// <summary>
/// Sets the fallback request handler that is invoked when no registered route /// GetRouteCollection method
/// matches an incoming request. Must be called before <see cref="Start"/>. /// </summary>
/// </summary>
/// <param name="handler">The handler to use as the server-level fallback.</param>
public void SetFallbackHandler(IHttpCwsHandler handler)
{
if (handler == null)
{
this.LogWarning("SetFallbackHandler: handler parameter is null, ignoring");
return;
}
_server.HttpRequestHandler = handler;
}
/// <summary>
/// GetRouteCollection method
/// </summary>
public HttpCwsRouteCollection GetRouteCollection() public HttpCwsRouteCollection GetRouteCollection()
{ {
return _server.Routes; return _server.Routes;
@ -208,24 +198,26 @@ namespace PepperDash.Core.Web
if (_server == null) if (_server == null)
{ {
this.LogWarning("Server is null, unable to start"); Debug.Console(DebugInfo, this, "Server is null, unable to start");
return; return;
} }
if (IsRegistered) if (IsRegistered)
{ {
this.LogWarning("Server has already been started"); Debug.Console(DebugInfo, this, "Server has already been started");
return; return;
} }
IsRegistered = _server.Register(); IsRegistered = _server.Register();
this.LogInformation("Starting server, registration {registrationResult}", IsRegistered ? "was successful" : "failed"); Debug.Console(DebugInfo, this, "Starting server, registration {0}", IsRegistered ? "was successful" : "failed");
} }
catch (Exception ex) catch (Exception ex)
{ {
this.LogError("Start Exception Message: {message}", ex.Message); Debug.Console(DebugInfo, this, "Start Exception Message: {0}", ex.Message);
this.LogDebug(ex, "Start Exception StackTrace"); Debug.Console(DebugVerbose, this, "Start Exception StackTrace: {0}", ex.StackTrace);
if (ex.InnerException != null)
Debug.Console(DebugVerbose, this, "Start Exception InnerException: {0}", ex.InnerException);
} }
finally finally
{ {
@ -233,9 +225,9 @@ namespace PepperDash.Core.Web
} }
} }
/// <summary> /// <summary>
/// Stop method /// Stop method
/// </summary> /// </summary>
public void Stop() public void Stop()
{ {
try try
@ -244,21 +236,23 @@ namespace PepperDash.Core.Web
if (_server == null) if (_server == null)
{ {
this.LogWarning("Server is null or has already been stopped"); Debug.Console(DebugInfo, this, "Server is null or has already been stopped");
return; return;
} }
IsRegistered = _server.Unregister() == false; IsRegistered = _server.Unregister() == false;
this.LogInformation("Stopping server, unregistration {unregistrationResult}", IsRegistered ? "failed" : "was successful"); Debug.Console(DebugInfo, this, "Stopping server, unregistration {0}", IsRegistered ? "failed" : "was successful");
_server.Dispose(); _server.Dispose();
_server = null; _server = null;
} }
catch (Exception ex) catch (Exception ex)
{ {
this.LogError("Server Stop Exception Message: {message}", ex.Message); Debug.Console(DebugInfo, this, "Server Stop Exception Message: {0}", ex.Message);
this.LogDebug(ex, "Server Stop Exception StackTrace"); 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);
} }
finally finally
{ {
@ -279,12 +273,14 @@ namespace PepperDash.Core.Web
try try
{ {
var j = JsonConvert.SerializeObject(args.Context, Formatting.Indented); var j = JsonConvert.SerializeObject(args.Context, Formatting.Indented);
this.LogVerbose("RecieveRequestEventHandler Context:\x0d\x0a{0}", j); Debug.Console(DebugVerbose, this, "RecieveRequestEventHandler Context:\x0d\x0a{0}", j);
} }
catch (Exception ex) catch (Exception ex)
{ {
this.LogError("ReceivedRequestEventHandler Exception Message: {message}", ex.Message); Debug.Console(DebugInfo, this, "ReceivedRequestEventHandler Exception Message: {0}", ex.Message);
this.LogDebug(ex, "ReceivedRequestEventHandler Exception StackTrace: {stackTrace}", ex.StackTrace); Debug.Console(DebugVerbose, this, "ReceivedRequestEventHandler Exception StackTrace: {0}", ex.StackTrace);
if (ex.InnerException != null)
Debug.Console(DebugVerbose, this, "ReceivedRequestEventHandler Exception InnerException: {0}", ex.InnerException);
} }
} }
} }

View file

@ -1,5 +1,5 @@
using System; using System;
using Crestron.SimplSharp; // For Basic SIMPL# Classes using Crestron.SimplSharp; // For Basic SIMPL# Classes
using Crestron.SimplSharp.CrestronIO; using Crestron.SimplSharp.CrestronIO;
using Crestron.SimplSharp.Net.Http; using Crestron.SimplSharp.Net.Http;
using Crestron.SimplSharp.Net.Https; using Crestron.SimplSharp.Net.Https;
@ -10,25 +10,24 @@ using PepperDash.Core.JsonToSimpl;
namespace PepperDash.Core.WebApi.Presets namespace PepperDash.Core.WebApi.Presets
{ {
/// <summary> /// <summary>
/// Passcode client for the WebApi /// Passcode client for the WebApi
/// </summary> /// </summary>
[Obsolete("WebApiPasscodeClient is no longer supported and will be removed in a future release.")]
public class WebApiPasscodeClient : IKeyed public class WebApiPasscodeClient : IKeyed
{ {
/// <summary> /// <summary>
/// Notifies when user received /// Notifies when user received
/// </summary> /// </summary>
public event EventHandler<UserReceivedEventArgs> UserReceived; public event EventHandler<UserReceivedEventArgs> UserReceived;
/// <summary> /// <summary>
/// Notifies when Preset received /// Notifies when Preset received
/// </summary> /// </summary>
public event EventHandler<PresetReceivedEventArgs> PresetReceived; public event EventHandler<PresetReceivedEventArgs> PresetReceived;
/// <summary> /// <summary>
/// Gets or sets the Key /// Gets or sets the Key
/// </summary> /// </summary>
public string Key { get; private set; } public string Key { get; private set; }
//string JsonMasterKey; //string JsonMasterKey;
@ -55,13 +54,13 @@ namespace PepperDash.Core.WebApi.Presets
{ {
} }
/// <summary> /// <summary>
/// Initializes the instance /// Initializes the instance
/// </summary> /// </summary>
/// <param name="key"></param> /// <param name="key"></param>
/// <param name="jsonMasterKey"></param> /// <param name="jsonMasterKey"></param>
/// <param name="urlBase"></param> /// <param name="urlBase"></param>
/// <param name="defaultPresetJsonFilePath"></param> /// <param name="defaultPresetJsonFilePath"></param>
public void Initialize(string key, string jsonMasterKey, string urlBase, string defaultPresetJsonFilePath) public void Initialize(string key, string jsonMasterKey, string urlBase, string defaultPresetJsonFilePath)
{ {
Key = key; Key = key;
@ -74,44 +73,44 @@ namespace PepperDash.Core.WebApi.Presets
J2SMaster.Initialize(jsonMasterKey); J2SMaster.Initialize(jsonMasterKey);
} }
/// <summary> /// <summary>
/// Gets the user for a passcode /// Gets the user for a passcode
/// </summary> /// </summary>
/// <param name="passcode"></param> /// <param name="passcode"></param>
/// <summary> /// <summary>
/// GetUserForPasscode method /// GetUserForPasscode method
/// </summary> /// </summary>
public void GetUserForPasscode(string passcode) public void GetUserForPasscode(string passcode)
{ {
// Bullshit duplicate code here... These two cases should be the same // Bullshit duplicate code here... These two cases should be the same
// except for https/http and the certificate ignores // except for https/http and the certificate ignores
if (!UrlBase.StartsWith("https")) if (!UrlBase.StartsWith("https"))
return; return;
var req = new HttpsClientRequest(); var req = new HttpsClientRequest();
req.Url = new UrlParser(UrlBase + "/api/users/dopin"); req.Url = new UrlParser(UrlBase + "/api/users/dopin");
req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post;
req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json"));
req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); req.Header.AddHeader(new HttpsHeader("Accept", "application/json"));
var jo = new JObject(); var jo = new JObject();
jo.Add("pin", passcode); jo.Add("pin", passcode);
req.ContentString = jo.ToString(); req.ContentString = jo.ToString();
var client = new HttpsClient(); var client = new HttpsClient();
client.HostVerification = false; client.HostVerification = false;
client.PeerVerification = false; client.PeerVerification = false;
var resp = client.Dispatch(req); var resp = client.Dispatch(req);
var handler = UserReceived; var handler = UserReceived;
if (resp.Code == 200) if (resp.Code == 200)
{ {
//CrestronConsole.PrintLine("Received: {0}", resp.ContentString); //CrestronConsole.PrintLine("Received: {0}", resp.ContentString);
var user = JsonConvert.DeserializeObject<User>(resp.ContentString); var user = JsonConvert.DeserializeObject<User>(resp.ContentString);
CurrentUser = user; CurrentUser = user;
if (handler != null) if (handler != null)
handler(this, new UserReceivedEventArgs(user, true)); UserReceived(this, new UserReceivedEventArgs(user, true));
} }
else else
if (handler != null) if (handler != null)
handler(this, new UserReceivedEventArgs(null, false)); UserReceived(this, new UserReceivedEventArgs(null, false));
} }
/// <summary> /// <summary>
@ -119,9 +118,9 @@ namespace PepperDash.Core.WebApi.Presets
/// </summary> /// </summary>
/// <param name="roomTypeId"></param> /// <param name="roomTypeId"></param>
/// <param name="presetNumber"></param> /// <param name="presetNumber"></param>
/// <summary> /// <summary>
/// GetPresetForThisUser method /// GetPresetForThisUser method
/// </summary> /// </summary>
public void GetPresetForThisUser(int roomTypeId, int presetNumber) public void GetPresetForThisUser(int roomTypeId, int presetNumber)
{ {
if (CurrentUser == null) if (CurrentUser == null)
@ -137,57 +136,57 @@ namespace PepperDash.Core.WebApi.Presets
PresetNumber = presetNumber PresetNumber = presetNumber
}; };
var handler = PresetReceived; var handler = PresetReceived;
try try
{ {
if (!UrlBase.StartsWith("https")) if (!UrlBase.StartsWith("https"))
return; return;
var req = new HttpsClientRequest(); var req = new HttpsClientRequest();
req.Url = new UrlParser(UrlBase + "/api/presets/userandroom"); req.Url = new UrlParser(UrlBase + "/api/presets/userandroom");
req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post;
req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json"));
req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); req.Header.AddHeader(new HttpsHeader("Accept", "application/json"));
req.ContentString = JsonConvert.SerializeObject(msg); req.ContentString = JsonConvert.SerializeObject(msg);
var client = new HttpsClient(); var client = new HttpsClient();
client.HostVerification = false; client.HostVerification = false;
client.PeerVerification = false; client.PeerVerification = false;
// ask for the preset // ask for the preset
var resp = client.Dispatch(req); var resp = client.Dispatch(req);
if (resp.Code == 200) // got it if (resp.Code == 200) // got it
{ {
//Debug.Console(1, this, "Received: {0}", resp.ContentString); //Debug.Console(1, this, "Received: {0}", resp.ContentString);
var preset = JsonConvert.DeserializeObject<Preset>(resp.ContentString); var preset = JsonConvert.DeserializeObject<Preset>(resp.ContentString);
CurrentPreset = preset; CurrentPreset = preset;
//if there's no preset data, load the template //if there's no preset data, load the template
if (preset.Data == null || preset.Data.Trim() == string.Empty || JObject.Parse(preset.Data).Count == 0) 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."); //Debug.Console(1, this, "Loaded preset has no data. Loading default template.");
LoadDefaultPresetData(); LoadDefaultPresetData();
return; return;
} }
J2SMaster.LoadWithJson(preset.Data); J2SMaster.LoadWithJson(preset.Data);
if (handler != null) if (handler != null)
handler(this, new PresetReceivedEventArgs(preset, true)); PresetReceived(this, new PresetReceivedEventArgs(preset, true));
} }
else // no existing preset else // no existing preset
{ {
CurrentPreset = new Preset(); CurrentPreset = new Preset();
LoadDefaultPresetData(); LoadDefaultPresetData();
if (handler != null) if (handler != null)
handler(this, new PresetReceivedEventArgs(null, false)); PresetReceived(this, new PresetReceivedEventArgs(null, false));
} }
} }
catch (HttpException e) catch (HttpException e)
{ {
var resp = e.Response; var resp = e.Response;
Debug.Console(1, this, "No preset received (code {0}). Loading default template", resp.Code); Debug.Console(1, this, "No preset received (code {0}). Loading default template", resp.Code);
LoadDefaultPresetData(); LoadDefaultPresetData();
if (handler != null) if (handler != null)
PresetReceived(this, new PresetReceivedEventArgs(null, false)); PresetReceived(this, new PresetReceivedEventArgs(null, false));
} }
} }
@ -219,14 +218,14 @@ namespace PepperDash.Core.WebApi.Presets
/// </summary> /// </summary>
/// <param name="roomTypeId"></param> /// <param name="roomTypeId"></param>
/// <param name="presetNumber"></param> /// <param name="presetNumber"></param>
/// <summary> /// <summary>
/// SavePresetForThisUser method /// SavePresetForThisUser method
/// </summary> /// </summary>
public void SavePresetForThisUser(int roomTypeId, int presetNumber) public void SavePresetForThisUser(int roomTypeId, int presetNumber)
{ {
if (CurrentPreset == null) if (CurrentPreset == null)
LoadDefaultPresetData(); LoadDefaultPresetData();
//return; //return;
//// A new preset needs to have its numbers set //// A new preset needs to have its numbers set
//if (CurrentPreset.IsNewPreset) //if (CurrentPreset.IsNewPreset)
@ -246,8 +245,8 @@ namespace PepperDash.Core.WebApi.Presets
{ {
CurrentPreset.Data = json; CurrentPreset.Data = json;
if (!UrlBase.StartsWith("https")) if (!UrlBase.StartsWith("https"))
return; return;
var req = new HttpsClientRequest(); var req = new HttpsClientRequest();
req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post;
req.Url = new UrlParser(string.Format("{0}/api/presets/addorchange", UrlBase)); req.Url = new UrlParser(string.Format("{0}/api/presets/addorchange", UrlBase));
@ -256,8 +255,8 @@ namespace PepperDash.Core.WebApi.Presets
req.ContentString = JsonConvert.SerializeObject(CurrentPreset); req.ContentString = JsonConvert.SerializeObject(CurrentPreset);
var client = new HttpsClient(); var client = new HttpsClient();
client.HostVerification = false; client.HostVerification = false;
client.PeerVerification = false; client.PeerVerification = false;
try try
{ {
var resp = client.Dispatch(req); var resp = client.Dispatch(req);

View file

@ -39,8 +39,6 @@ namespace PepperDash.Essentials.Core
ICec Port; ICec Port;
bool _cecSubscribed;
/// <summary> /// <summary>
/// Constructor /// Constructor
/// </summary> /// </summary>
@ -56,7 +54,7 @@ namespace PepperDash.Essentials.Core
{ {
Port = postActivationFunc(config); Port = postActivationFunc(config);
TryEnsureCecSubscription(); Port.StreamCec.CecChange += StreamCec_CecChange;
}); });
} }
@ -70,29 +68,7 @@ namespace PepperDash.Essentials.Core
{ {
Port = port; Port = port;
TryEnsureCecSubscription();
}
/// <summary>
/// Subscribes to the CEC change event once <see cref="ICec.StreamCec"/> 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.
/// </summary>
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); 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) void StreamCec_CecChange(Cec cecDevice, CecEventArgs args)
@ -128,9 +104,8 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
public void SendText(string text) public void SendText(string text)
{ {
if (Port?.StreamCec == null) if (Port == null)
return; return;
TryEnsureCecSubscription();
this.PrintSentText(text); this.PrintSentText(text);
Port.StreamCec.Send.StringValue = text; Port.StreamCec.Send.StringValue = text;
} }
@ -140,9 +115,8 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
public void SendBytes(byte[] bytes) public void SendBytes(byte[] bytes)
{ {
if (Port?.StreamCec == null) if (Port == null)
return; return;
TryEnsureCecSubscription();
var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length);
this.PrintSentBytes(bytes); this.PrintSentBytes(bytes);
Debug.LogMessage(LogEventLevel.Information, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes)); Debug.LogMessage(LogEventLevel.Information, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes));

View file

@ -96,17 +96,6 @@ namespace PepperDash.Essentials.Core
comm = udp; comm = udp;
break; 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: case eControlMethod.Telnet:
break; break;
case eControlMethod.SecureTcpIp: case eControlMethod.SecureTcpIp:

View file

@ -135,14 +135,12 @@ namespace PepperDash.Essentials.Core.Config
{ {
var parsedConfig = JObject.Parse(fs.ReadToEnd()); var parsedConfig = JObject.Parse(fs.ReadToEnd());
// A config is v1 if it has separate "system" and "template" nodes that // Check if it's a v2 config (check for "version" node)
// need to be merged. A v2 config is already merged by the Portal API and // this means it's already merged by the Portal API
// will not have "system"/"template" nodes. This is independent of whether // from the v2 config tool
// a "versions" node is present, which only carries version metadata and var isV2Config = parsedConfig["versions"] != null;
// can appear on either a v1 or v2 config.
var isV1Config = parsedConfig["system"] != null && parsedConfig["template"] != null; if (isV2Config)
if (!isV1Config)
{ {
Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary."); Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary.");
ConfigObject = parsedConfig.ToObject<EssentialsConfig>(); ConfigObject = parsedConfig.ToObject<EssentialsConfig>();
@ -150,8 +148,6 @@ namespace PepperDash.Essentials.Core.Config
return true; return true;
} }
Debug.LogMessage(LogEventLevel.Information, "Config file is a v1 format, merging system and template.");
// Extract SystemUrl and TemplateUrl into final config output // Extract SystemUrl and TemplateUrl into final config output
ConfigObject = PortalConfigReader.MergeConfigs(parsedConfig).ToObject<EssentialsConfig>(); ConfigObject = PortalConfigReader.MergeConfigs(parsedConfig).ToObject<EssentialsConfig>();
@ -164,13 +160,6 @@ namespace PepperDash.Essentials.Core.Config
{ {
ConfigObject.TemplateUrl = parsedConfig["template_url"].Value<string>(); ConfigObject.TemplateUrl = parsedConfig["template_url"].Value<string>();
} }
// 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<VersionData>();
}
} }
Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config"); Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config");

View file

@ -19,7 +19,6 @@ namespace PepperDash.Essentials.Core.Config
/// <summary> /// <summary>
/// ConfigUpdater class /// ConfigUpdater class
/// </summary> /// </summary>
[Obsolete("ConfigUpdater is no longer supported and will be removed in a future release.")]
public static class ConfigUpdater public static class ConfigUpdater
{ {
/// <summary> /// <summary>
@ -82,7 +81,7 @@ namespace PepperDash.Essentials.Core.Config
{ {
var handler = ConfigStatusChanged; var handler = ConfigStatusChanged;
if (handler != null) if(handler != null)
{ {
handler(typeof(ConfigUpdater), new ConfigStatusEventArgs(status)); handler(typeof(ConfigUpdater), new ConfigStatusEventArgs(status));
} }
@ -90,7 +89,7 @@ namespace PepperDash.Essentials.Core.Config
static void WriteConfigToFile(string configData) static void WriteConfigToFile(string configData)
{ {
var filePath = Global.FilePathPrefix + "configurationFile-updated.json"; var filePath = Global.FilePathPrefix+ "configurationFile-updated.json";
try try
{ {
@ -105,7 +104,7 @@ namespace PepperDash.Essentials.Core.Config
Debug.LogMessage(LogEventLevel.Debug, "Error parsing new config: {0}", e); Debug.LogMessage(LogEventLevel.Debug, "Error parsing new config: {0}", e);
OnStatusUpdate(eUpdateStatus.UpdateFailed); OnStatusUpdate(eUpdateStatus.UpdateFailed);
} }
} }
/// <summary> /// <summary>
@ -150,11 +149,11 @@ namespace PepperDash.Essentials.Core.Config
// Directory exists, first clear any contents // Directory exists, first clear any contents
var archivedConfigFiles = ConfigReader.GetConfigFiles(archiveDirectoryPath + Global.DirectorySeparator + Global.ConfigFileName + ".bak"); 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); 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]; var file = archivedConfigFiles[i];
Debug.LogMessage(LogEventLevel.Information, "Deleting archived file: '{0}'", file.FullName); Debug.LogMessage(LogEventLevel.Information, "Deleting archived file: '{0}'", file.FullName);
@ -171,9 +170,9 @@ namespace PepperDash.Essentials.Core.Config
// Moves the file and appends the .bak extension // Moves the file and appends the .bak extension
var fileDest = archiveDirectoryPath + "/" + file.Name + ".bak"; var fileDest = archiveDirectoryPath + "/" + file.Name + ".bak";
if (!File.Exists(fileDest)) if(!File.Exists(fileDest))
{ {
file.MoveTo(fileDest); file.MoveTo(fileDest);
} }
else else
Debug.LogMessage(LogEventLevel.Information, "Cannot move file to archive folder. Existing file already exists with same name: '{0}'", fileDest); Debug.LogMessage(LogEventLevel.Information, "Cannot move file to archive folder. Existing file already exists with same name: '{0}'", fileDest);
@ -208,15 +207,15 @@ namespace PepperDash.Essentials.Core.Config
CrestronConsole.SendControlSystemCommand(string.Format("progreset -p:{0}", InitialParametersClass.ApplicationNumber), ref response); 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);
} }
} }
/// <summary> /// <summary>
/// Enumeration of eUpdateStatus values /// Enumeration of eUpdateStatus values
/// </summary> /// </summary>
public enum eUpdateStatus public enum eUpdateStatus
{ {
/// <summary> /// <summary>
/// UpdateStarted status /// UpdateStarted status

View file

@ -105,7 +105,6 @@ namespace PepperDash.Essentials.Core.Config
/// <summary> /// <summary>
/// Gets or sets the Versions /// Gets or sets the Versions
/// </summary> /// </summary>
[JsonProperty("versions")]
public VersionData Versions { get; set; } public VersionData Versions { get; set; }
/// <summary> /// <summary>
@ -135,25 +134,12 @@ namespace PepperDash.Essentials.Core.Config
[JsonProperty("packages")] [JsonProperty("packages")]
public List<NugetVersion> Packages { get; set; } public List<NugetVersion> Packages { get; set; }
/// <summary>
/// Gets or sets the touchpanel wrapper app version
/// </summary>
[JsonProperty("touchpanelWrapperApp")]
public NugetVersion TouchpanelWrapperApp { get; set; }
/// <summary>
/// Gets or sets the list of user interface packages
/// </summary>
[JsonProperty("userInterfaces")]
public List<NugetVersion> UserInterfaces { get; set; }
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="VersionData"/> class. /// Initializes a new instance of the <see cref="VersionData"/> class.
/// </summary> /// </summary>
public VersionData() public VersionData()
{ {
Packages = new List<NugetVersion>(); Packages = new List<NugetVersion>();
UserInterfaces = new List<NugetVersion>();
} }
} }
@ -171,20 +157,8 @@ namespace PepperDash.Essentials.Core.Config
/// <summary> /// <summary>
/// Gets or sets the PackageId /// Gets or sets the PackageId
/// </summary> /// </summary>
[JsonProperty("packageId", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("packageId")]
public string PackageId { get; set; } public string PackageId { get; set; }
/// <summary>
/// Gets or sets the RepoUrl
/// </summary>
[JsonProperty("repoUrl", NullValueHandling = NullValueHandling.Ignore)]
public string RepoUrl { get; set; }
/// <summary>
/// Gets or sets the human-readable name
/// </summary>
[JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)]
public string Name { get; set; }
} }
/// <summary> /// <summary>

View file

@ -3,291 +3,9 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{ {
/// <summary>
/// Defines the display mode for a webview event, with expected values of "Fullscreen", "Modal", or "Unknown".
/// </summary>
public enum eWebViewEventMode
{
/// <summary>
/// The display mode for the webview event is unknown or not specified. This value can be used as a default or fallback when the display mode is not provided or cannot be parsed into a known value.
/// </summary>
Unknown,
/// <summary>
/// The webview event should be displayed in fullscreen mode, covering the entire screen and typically used for immersive experiences or when maximum screen real estate is needed. When a webview event with this display mode is shown, it will typically trigger the WebViewStatusChanged event with a status of "Fullscreen", and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared".
/// </summary>
Fullscreen,
/// <summary>
/// The webview event should be displayed in modal mode, which typically means it will be shown as a dialog or overlay on top of the existing content, allowing the user to interact with it while still being able to see the underlying content. This display mode is often used for alerts, confirmations, or when the webview content is related to the current context but does not require full immersion. When a webview event with this display mode is shown, it will typically trigger the WebViewStatusChanged event with a status of "Modal", and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared".
/// </summary>
Modal,
}
/// <summary>
/// Defines the target for a webview event, with expected values of "OSD", "Controller", "PersistentWebApp", or "RoomScheduler".
/// </summary>
public enum eWebViewTarget
{
/// <summary>
/// The target for the webview event is unknown or not specified. This value can be used as a default or fallback when the target is not provided or cannot be parsed into a known value.
/// </summary>
Unknown,
/// <summary>
/// The webview event should be displayed on the On-Screen Display (OSD).
/// </summary>
OSD,
/// <summary>
/// The webview event should be displayed on the controller.
/// </summary>
Controller,
/// <summary>
/// The webview event should be displayed on the persistent web application.
/// </summary>
PersistentWebApp,
/// <summary>
/// The webview event should be displayed on the room scheduler.
/// </summary>
RoomScheduler
}
/// <summary>
/// Represents the reason for an error in a webview event, which can provide additional information about what went wrong. This class is typically only used in the Status property of a WebViewEvent when the status indicates an error, and may be null otherwise.
/// </summary>
public class Reason
{
/// <summary>
/// The reason for an error in a webview event as a string, which can provide additional information about what went wrong. This property is typically only populated in case of an error, and may be null otherwise.
/// </summary>
public string Value { get; set; }
}
/// <summary>
/// Represents the XPath of a webview event, which can provide information about where an error occurred in the webview. This class is typically only used in the Status property of a WebViewEvent when the status indicates an error, and may be null otherwise.
/// </summary>
public class XPath
{
/// <summary>
/// The XPath of a webview event as a string, which can provide information about where an error occurred in the webview. This property is typically only populated in case of an error, and may be null otherwise.
/// </summary>
public string Value { get; set; }
}
/// <summary>
/// Represents a base class for properties that have a string value and trigger an action when the value changes. This class can be used as a base for properties like DisplayMode and Target in the WebViewEvent, which have string values that can be set directly or parsed into enums for easier handling of expected values. The ValueChangedAction can be set to trigger any desired behavior when the value changes, such as updating the UI or triggering other events.
/// </summary>
public abstract class ValueProperty
{
/// <summary>
/// Triggered when Value is set
/// </summary>
public Action ValueChangedAction { get; set; }
/// <summary>
/// Triggers the ValueChangedAction if it is set. This method should be called whenever the Value property is set to ensure that any desired behavior associated with a change in value is executed.
/// </summary>
protected void OnValueChanged()
{
var a = ValueChangedAction;
if (a != null)
a();
}
}
/// <summary>
/// Represents a webview event, which can include information about the status of the webview, the display parameters for the webview, and any error information if applicable. This class can be used to represent both show and clear events for a webview, with the Status property indicating the current status of the webview (e.g., "Fullscreen", "Modal", "Cleared", "Error", or "Unknown"), the Display property providing details about how the webview is being displayed (e.g., mode, URL, target, title), and the Cleared property providing details about a cleared/closed webview event (e.g., target and ID). The Id property can be used to correlate show and clear events for the same webview instance.
/// </summary>
public class WebViewEvent
{
/// <summary>
/// The unique identifier for the webview event, which can be used to correlate show and clear events for the same webview instance. This property is typically included in both show and clear events for a webview, allowing you to track the lifecycle of a specific webview instance from when it is shown to when it is cleared/closed. The Id can be any string value, but it should be unique for each webview instance to ensure proper correlation between show and clear events.
/// </summary>
[JsonProperty("id")]
public string Id { get; set; }
/// <summary>
/// The status of the webview event, which can indicate the current state of the webview (e.g., "Fullscreen", "Modal", "Cleared", "Error", or "Unknown") as well as any error information if applicable (XPath and Reason). The Value property can be used to get or set the current status of the webview, while the XPath and Reason properties can provide additional information in case of an error. The StatusString property can be used to get or set the raw status string from the event, but it is recommended to use the Value property for easier handling of expected values. Setting the Value property will trigger the ValueChangedAction if it is set, allowing you to respond to changes in the webview status as needed.
/// </summary>
[JsonProperty("status")]
public Status Status { get; set; } // /Event/UserInterface/WebView/Status
/// <summary>
/// The display parameters for the webview event, which can include the display mode (e.g., "Fullscreen", "Modal", or "Unknown"), the URL to display in the webview, the target for the webview (e.g., "OSD", "Controller", "PersistentWebApp", or "RoomScheduler"), and the title to display on the webview. This property is typically included in show events for a webview, providing details about how the webview is being displayed. When a webview event with these display parameters is shown, it will typically trigger the WebViewStatusChanged event with a status of "Fullscreen" or "Modal" depending on the specified display mode, and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared".
/// </summary>
[JsonProperty("display")]
public WebViewDisplay Display { get; set; } // /Event/UserInterface/WebView/Display
/// <summary>
/// The details for a cleared/closed webview event, which can include the target for the webview that was cleared (e.g., "OSD", "Controller", "PersistentWebApp", or "RoomScheduler") and the unique identifier for the webview event that was cleared. This property is typically included in clear events for a webview, providing details about which webview instance was cleared/closed. When a webview event with this property is cleared/closed, it will typically trigger the WebViewStatusChanged event with a status of "Cleared".
/// </summary>
[JsonProperty("cleared")]
public WebViewClear Cleared { get; set; } // /Event/UserInterface/WebView/Cleared
}
/// <summary>
/// Represents the display parameters for a webview event, which can include the display mode (e.g., "Fullscreen", "Modal", or "Unknown"), the URL to display in the webview, the target for the webview (e.g., "OSD", "Controller", "PersistentWebApp", or "RoomScheduler"), and the title to display on the webview. This class is typically used in the Display property of a WebViewEvent to provide details about how the webview is being displayed when a show event occurs. When a webview event with these display parameters is shown, it will typically trigger the WebViewStatusChanged event with a status of "Fullscreen" or "Modal" depending on the specified display mode, and when it is cleared/closed, it will trigger the WebViewStatusChanged event with a status of "Cleared".
/// </summary>
public class WebViewDisplay
{
/// <summary>
/// The display mode for the webview event. Expected values are "Fullscreen", "Modal", or "Unknown".
/// </summary>
[JsonProperty("mode")]
public DisplayMode Mode { get; set; }
/// <summary>
/// The URL to display in the webview.
/// </summary>
[JsonProperty("url")]
public string Url { get; set; }
/// <summary>
/// The target for the webview. Expected values are "OSD", "Controller", "PersistentWebApp", or "RoomScheduler".
/// </summary>
[JsonProperty("target")]
public Target Target { get; set; }
/// <summary>
/// The title to display on the webview.
/// </summary>
[JsonProperty("title")]
public string Title { get; set; }
/// <summary>
/// The unique identifier for the webview event, used to correlate show and clear events for the same webview instance.
/// </summary>
[JsonProperty("id")]
public string Id { get; set; }
}
/// <summary>
/// Represents the data for a webview cleared event, which indicates that a webview with the specified ID and target has been cleared/closed.
/// </summary>
public class WebViewClear
{
/// <summary>
/// The target for the webview that was cleared. Expected values are "OSD", "Controller", "PersistentWebApp", or "RoomScheduler".
/// </summary>
[JsonProperty("target")]
public Target Target { get; set; }
/// <summary>
/// The unique identifier for the webview event that was cleared, used to correlate show and clear events for the same webview instance.
/// </summary>
[JsonProperty("id")]
public string Id { get; set; }
/// <summary>
/// The URL that was displayed in the webview that was cleared.
/// </summary>
[JsonProperty("url")]
public string Url { get; set; }
}
/// <summary>
/// Represents the display mode for a webview event, with a string value and a corresponding enum property for easier handling of expected values.
/// </summary>
public class DisplayMode : ValueProperty
{
private string _value;
/// <summary>
/// The id of the webview event.
/// </summary>
[JsonProperty("id")]
public string Id { get; set; }
/// <summary>
/// The string value for the display mode, which can be set directly or parsed into the WebViewEventMode enum using the WebViewEventMode property. Setting this property will also trigger the ValueChangedAction if it is set.
/// </summary>
public string Value { get { return _value; } set { _value = value; OnValueChanged(); } }
/// <summary>
/// The display mode for the webview event as an enum, which can be used for easier handling of expected values. Expected values are Fullscreen, Modal, or Unknown.
/// </summary>
public eWebViewEventMode WebViewEventMode
{
get
{
eWebViewEventMode mode;
System.Enum.TryParse(Value, true, out mode);
return mode;
}
}
}
/// <summary>
/// Represents the target for a webview event, with a string value and a corresponding enum property for easier handling of expected values. Setting the Value property will also trigger the ValueChangedAction if it is set.
/// </summary>
public class Target : ValueProperty
{
private string _value;
/// <summary>
/// The id of the webview event.
/// </summary>
[JsonProperty("id")]
public string Id { get; set; }
/// <summary>
/// The string value for the target, which can be set directly or parsed into the eWebViewTarget enum using the WebViewTarget property. Expected values are "OSD", "Controller", "PersistentWebApp", or "RoomScheduler". Setting this property will also trigger the ValueChangedAction if it is set.
/// </summary>
public string Value { get { return _value; } set { _value = value; OnValueChanged(); } }
/// <summary>
/// The target for the webview event as an enum, which can be used for easier handling of expected values. Expected values are OSD, Controller, PersistentWebApp, or RoomScheduler.
/// </summary>
public eWebViewTarget WebViewTarget
{
get
{
eWebViewTarget target;
System.Enum.TryParse(Value, true, out target);
return target;
}
}
}
/// <summary>
/// Represents the status of a webview event, which can include error information (XPath and Reason) as well as the current status of the webview. The Value property can be used to get or set the current status of the webview, while the XPath and Reason properties can provide additional information in case of an error. The StatusString property can be used to get or set the raw status string from the event.
/// </summary>
public class Status
{
/// <summary>
/// The XPath of the webview event, which can provide information about where an error occurred in the webview. This property is typically only populated in case of an error, and may be null otherwise.
/// </summary>
[JsonProperty("XPath", NullValueHandling = NullValueHandling.Ignore)]
public XPath XPath { get; set; }
/// <summary>
/// The reason for an error in the webview event, which can provide additional information about what went wrong. This property is typically only populated in case of an error, and may be null otherwise.
/// </summary>
[JsonProperty("Reason", NullValueHandling = NullValueHandling.Ignore)]
public Reason Reason { get; set; }
/// <summary>
/// The raw status string from the webview event, which can provide information about the current status of the webview. This property can be used to get or set the status directly, but it is recommended to use the Value property for easier handling of expected values. Setting this property will not trigger any actions, while setting the Value property will trigger the ValueChangedAction if it is set.
/// </summary>
[JsonProperty("status", NullValueHandling = NullValueHandling.Ignore)]
public string StatusString { get; set; }
/// <summary>
/// The current status of the webview as a string, which can be set directly or parsed into a WebViewEventMode enum using the WebViewEventMode property. Expected values are "Fullscreen", "Modal", "Cleared", "Error", or "Unknown". Setting this property will trigger the ValueChangedAction if it is set.
/// </summary>
[JsonProperty("Value", NullValueHandling = NullValueHandling.Ignore)]
public string Value { get; set; }
}
/// <summary> /// <summary>
/// Defines the contract for IHasWebView /// Defines the contract for IHasWebView
/// </summary> /// </summary>
@ -316,7 +34,6 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
/// Event raised when the webview status changes /// Event raised when the webview status changes
/// </summary> /// </summary>
event EventHandler<WebViewStatusChangedEventArgs> WebViewStatusChanged; event EventHandler<WebViewStatusChangedEventArgs> WebViewStatusChanged;
} }
@ -358,11 +75,6 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
/// </summary> /// </summary>
public string Status { get; } public string Status { get; }
/// <summary>
/// Gets or sets the WebViewEvent associated with the status change, which can provide additional information about the webview event that triggered the status change, such as display parameters or error information. This property allows you to include the full WebViewEvent in the event args, giving you access to all relevant details about the webview event when handling the WebViewStatusChanged event.
/// </summary>
public WebViewEvent WebView { get; }
/// <summary> /// <summary>
/// Constructor for WebViewStatusChangedEventArgs /// Constructor for WebViewStatusChangedEventArgs
/// </summary> /// </summary>
@ -371,16 +83,5 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{ {
Status = status; Status = status;
} }
/// <summary>
/// Constructor for WebViewStatusChangedEventArgs with WebViewEvent parameter, which can provide additional information about the webview event that triggered the status change, such as display parameters or error information. This constructor allows you to include the full WebViewEvent in the event args, giving you access to all relevant details about the webview event when handling the WebViewStatusChanged event.
/// </summary>
/// <param name="status">the new status of the webview</param>
/// <param name="webview">the WebViewEvent associated with the status change</param>
public WebViewStatusChangedEventArgs(string status, WebViewEvent webview)
{
Status = status;
WebView = webview;
}
} }
} }

View file

@ -1,48 +0,0 @@
using System;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This refers specifically to wireless screen/device mirroring, as distinct from in-call
/// content sharing on a video conference.
/// </remarks>
public interface IHasWirelessSharing
{
/// <summary>
/// Reports whether a wireless sharing session is currently active (content is being presented).
/// </summary>
BoolFeedback IsSharingFeedback { get; }
/// <summary>
/// Raised when wireless sharing starts or stops. The event args carry the new sharing state.
/// </summary>
event EventHandler<WirelessSharingEventArgs> SharingChanged;
}
/// <summary>
/// Event arguments describing a change in wireless sharing state.
/// </summary>
public class WirelessSharingEventArgs : EventArgs
{
/// <summary>
/// True if a wireless sharing session is active (content is being presented), false otherwise.
/// </summary>
public bool IsSharing { get; private set; }
/// <summary>
/// Creates a new <see cref="WirelessSharingEventArgs"/>.
/// </summary>
/// <param name="isSharing">True if a wireless sharing session is active, false otherwise.</param>
public WirelessSharingEventArgs(bool isSharing)
{
IsSharing = isSharing;
}
}
}

View file

@ -1,12 +1,10 @@
using System;
using PepperDash.Core; using PepperDash.Core;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{ {
/// <summary> /// <summary>
/// Obsolete: messengers are subscription based by default; use IMobileControlMessenger instead. /// Defines the contract for IMobileControlMessenger
/// </summary> /// </summary>
[Obsolete("This interface is obsolete and will be removed in a future version. All messengers are now subscription based.")]
public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger
{ {
/// <summary> /// <summary>

View file

@ -1,122 +0,0 @@
using System;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
/// <summary>
/// Interface for network switches that support VLAN assignment on individual ports.
/// </summary>
public interface INetworkSwitchVlanManager
{
/// <summary>
/// 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).
/// </summary>
/// <param name="port">Switch port identifier</param>
/// <returns>VLAN ID or -1 when unavailable</returns>
int GetPortCurrentVlan(string port);
/// <summary>
/// Changes the access VLAN of a single switch port.
/// The implementation is responsible for entering/exiting privileged/config mode.
/// </summary>
/// <param name="port">Switch port identifier (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco)</param>
/// <param name="vlanId">Target VLAN ID (1-4093)</param>
void SetPortVlan(string port, uint vlanId);
}
/// <summary>
/// Interface for network switches that support Power over Ethernet (PoE) control on individual ports.
/// </summary>
public interface INetworkSwitchPoeManager
{
/// <summary>
/// Enables or disables PoE power delivery on a single switch port.
/// The implementation is responsible for entering/exiting privileged/config mode.
/// </summary>
/// <param name="port">Switch port identifier</param>
/// <param name="enabled">True to enable PoE; false to disable PoE</param>
void SetPortPoeState(string port, bool enabled);
}
/// <summary>
/// Standardized interface for network switch devices that support per-port PoE control
/// and VLAN assignment.
/// </summary>
public interface INetworkSwitchPoeVlanManager : INetworkSwitchVlanManager, INetworkSwitchPoeManager
{
/// <summary>
/// Event that is raised when the state of a switch port changes, such as a VLAN change or PoE state change.
/// </summary>
event EventHandler<NetworkSwitchPortEventArgs> PortStateChanged;
}
/// <summary>
/// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes.
/// </summary>
public class NetworkSwitchPortEventArgs : EventArgs
{
/// <summary>
/// The identifier of the port that changed state (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco).
/// </summary>
public string Port { get; private set; }
/// <summary>
/// The type of event that occurred on the port (e.g. VLAN change, PoE enabled/disabled).
/// </summary>
public NetworkSwitchPortEventType EventType { get; private set; }
/// <summary>
/// Constructor for NetworkSwitchPortEventArgs
/// </summary>
/// <param name="port">The identifier of the port that changed state</param>
/// <param name="eventType">The type of event that occurred on the port</param>
public NetworkSwitchPortEventArgs(string port, NetworkSwitchPortEventType eventType)
{
Port = port;
EventType = eventType;
}
}
/// <summary>
/// Enumeration of network switch port state change event types (e.g. VLAN changes or PoE state changes).
/// </summary>
public enum NetworkSwitchPortEventType
{
/// <summary>
/// Indicates that the type of event is unknown or cannot be determined.
/// </summary>
Unknown,
/// <summary>
/// Indicates that a VLAN change is in progress on the port, either through a call to SetPortVlan or an external change detected by polling.
/// </summary>
VlanChangeInProgress,
/// <summary>
/// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan or an external change detected by polling.
/// </summary>
VlanChanged,
/// <summary>
/// Indicates that PoE is being disabled on the port, either through a call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoeDisableInProgress,
/// <summary>
/// Indicates that PoE has been disabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoEDisabled,
/// <summary>
/// Indicates that PoE is being enabled on the port, either through a call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoeEnableInProgress,
/// <summary>
/// Indicates that PoE has been enabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling.
/// </summary>
PoEEnabled
}
}

View file

@ -7,7 +7,6 @@ using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace PepperDash.Essentials.Core namespace PepperDash.Essentials.Core
@ -177,20 +176,7 @@ namespace PepperDash.Essentials.Core
{ {
if (!conversionType.IsEnum) if (!conversionType.IsEnum)
{ {
if (conversionType == typeof(byte[]) && value is string byteString) return Convert.ChangeType(value, conversionType, System.Globalization.CultureInfo.InvariantCulture);
{
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); var stringValue = Convert.ToString(value);
@ -203,32 +189,6 @@ namespace PepperDash.Essentials.Core
return Enum.Parse(conversionType, stringValue, true); return Enum.Parse(conversionType, stringValue, true);
} }
/// <summary>
/// Processes escape sequences in a string, converting sequences like \r, \n, \t, \xHH
/// to their corresponding non-printable ASCII characters.
/// </summary>
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();
}
});
}
/// <summary> /// <summary>
/// Gets the properties on a device /// Gets the properties on a device
/// </summary> /// </summary>

View file

@ -1,14 +0,0 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines the contract for IHasDspPresetSave
/// </summary>
public interface IHasDspPresetSave : IDspPresets
{
/// <summary>
/// Saves the preset by key
/// </summary>
/// <param name="key">key of preset to save</param>
void SavePreset(string key);
}
}

View file

@ -7,10 +7,9 @@ using PepperDash.Core;
namespace PepperDash.Essentials.Core.Interfaces namespace PepperDash.Essentials.Core.Interfaces
{ {
/// <summary> /// <summary>
/// Defines the contract for ILogStrings /// Defines the contract for ILogStrings
/// </summary> /// </summary>
[Obsolete("ILogStrings is no longer supported and will be removed in a future release.")]
public interface ILogStrings : IKeyed public interface ILogStrings : IKeyed
{ {
/// <summary> /// <summary>

View file

@ -7,16 +7,15 @@ using PepperDash.Core;
namespace PepperDash.Essentials.Core.Interfaces namespace PepperDash.Essentials.Core.Interfaces
{ {
/// <summary> /// <summary>
/// Defines the contract for ILogStringsWithLevel /// Defines the contract for ILogStringsWithLevel
/// </summary> /// </summary>
[Obsolete("ILogStringsWithLevel is no longer supported and will be removed in a future release.")]
public interface ILogStringsWithLevel : IKeyed public interface ILogStringsWithLevel : IKeyed
{ {
/// <summary> /// <summary>
/// Defines a class that is capable of logging a string with an int level /// Defines a class that is capable of logging a string with an int level
/// </summary> /// </summary>
void SendToLog(IKeyed device, Debug.ErrorLogLevel level, string logMessage); void SendToLog(IKeyed device, Debug.ErrorLogLevel level,string logMessage);
} }
} }

View file

@ -25,7 +25,7 @@
<DocumentationFile>bin\$(Configuration)\PepperDash_Essentials_Core.xml</DocumentationFile> <DocumentationFile>bin\$(Configuration)\PepperDash_Essentials_Core.xml</DocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="Crestron\CrestronGenericBaseDevice.cs.orig" /> <None Include="Crestron\CrestronGenericBaseDevice.cs.orig" />

View file

@ -2,8 +2,10 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using Crestron.SimplSharpPro.Keypads;
using PepperDash.Essentials.Core.Queues; using PepperDash.Essentials.Core.Queues;
using PepperDash.Essentials.Core.Routing; using PepperDash.Essentials.Core.Routing;
using Serilog.Events;
using Debug = PepperDash.Core.Debug; using Debug = PepperDash.Core.Debug;
@ -51,6 +53,12 @@ namespace PepperDash.Essentials.Core
/// </summary> /// </summary>
private static Dictionary<string, List<TieLine>> _tieLinesBySource; private static Dictionary<string, List<TieLine>> _tieLinesBySource;
/// <summary>
/// Cache of failed route attempts to avoid re-checking impossible paths.
/// Format: "sourceKey|destKey|signalType"
/// </summary>
private static readonly HashSet<string> _impossibleRoutes = new HashSet<string>();
/// <summary> /// <summary>
/// Indexes all TieLines by source and destination device keys for faster lookups. /// Indexes all TieLines by source and destination device keys for faster lookups.
/// Should be called once at system startup after all TieLines are created. /// Should be called once at system startup after all TieLines are created.
@ -59,7 +67,7 @@ namespace PepperDash.Essentials.Core
{ {
try try
{ {
Debug.LogInformation("Indexing TieLines for faster route discovery"); Debug.LogMessage(LogEventLevel.Information, "Indexing TieLines for faster route discovery");
_tieLinesByDestination = TieLineCollection.Default _tieLinesByDestination = TieLineCollection.Default
.GroupBy(t => t.DestinationPort.ParentDevice.Key) .GroupBy(t => t.DestinationPort.ParentDevice.Key)
@ -69,8 +77,8 @@ namespace PepperDash.Essentials.Core
.GroupBy(t => t.SourcePort.ParentDevice.Key) .GroupBy(t => t.SourcePort.ParentDevice.Key)
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
Debug.LogInformation("TieLine indexing complete. {0} destination keys, {1} source keys", Debug.LogMessage(LogEventLevel.Information, "TieLine indexing complete. {0} destination keys, {1} source keys",
_tieLinesByDestination.Count, _tieLinesBySource.Count); null, _tieLinesByDestination.Count, _tieLinesBySource.Count);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -113,6 +121,27 @@ namespace PepperDash.Essentials.Core
return TieLineCollection.Default.Where(t => t.SourcePort.ParentDevice.Key == sourceKey); return TieLineCollection.Default.Where(t => t.SourcePort.ParentDevice.Key == sourceKey);
} }
/// <summary>
/// Creates a cache key for route impossibility tracking.
/// </summary>
/// <param name="sourceKey">Source device key</param>
/// <param name="destKey">Destination device key</param>
/// <param name="type">Signal type</param>
/// <returns>Cache key string</returns>
private static string GetRouteKey(string sourceKey, string destKey, eRoutingSignalType type)
{
return string.Format("{0}|{1}|{2}", sourceKey, destKey, type);
}
/// <summary>
/// Clears the impossible routes cache. Should be called if TieLines are added/removed at runtime.
/// </summary>
public static void ClearImpossibleRoutesCache()
{
_impossibleRoutes.Clear();
Debug.LogMessage(LogEventLevel.Information, "Impossible routes cache cleared");
}
/// <summary> /// <summary>
/// Gets any existing RouteDescriptor for a destination, clears it using ReleaseRoute /// Gets any existing RouteDescriptor for a destination, clears it using ReleaseRoute
/// and then attempts a new Route and if sucessful, stores that RouteDescriptor /// and then attempts a new Route and if sucessful, stores that RouteDescriptor
@ -122,7 +151,7 @@ namespace PepperDash.Essentials.Core
{ {
// Remove this line before committing!!!!! // Remove this line before committing!!!!!
var frame = new StackFrame(1, true); var frame = new StackFrame(1, true);
Debug.LogInformation("ReleaseAndMakeRoute Called from {method} with params {destinationKey}:{sourceKey}:{signalType}:{destinationPortKey}:{sourcePortKey}", frame.GetMethod().Name, destination.Key, source.Key, signalType.ToString(), destinationPortKey, sourcePortKey); 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);
var inputPort = string.IsNullOrEmpty(destinationPortKey) ? null : destination.InputPorts.FirstOrDefault(p => p.Key == destinationPortKey); 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); var outputPort = string.IsNullOrEmpty(sourcePortKey) ? null : source.OutputPorts.FirstOrDefault(p => p.Key == sourcePortKey);
@ -180,13 +209,13 @@ namespace PepperDash.Essentials.Core
/// <param name="destinationKey">destination device key</param> /// <param name="destinationKey">destination device key</param>
public static void RemoveRouteRequestForDestination(string destinationKey) public static void RemoveRouteRequestForDestination(string destinationKey)
{ {
Debug.LogInformation("Removing route request for {destination}", destinationKey); Debug.LogMessage(LogEventLevel.Information, "Removing route request for {destination}", null, destinationKey);
var result = RouteRequests.Remove(destinationKey); var result = RouteRequests.Remove(destinationKey);
var messageTemplate = result ? "Route Request for {destination} removed" : "Route Request for {destination} not found"; var messageTemplate = result ? "Route Request for {destination} removed" : "Route Request for {destination} not found";
Debug.LogInformation(messageTemplate, destinationKey); Debug.LogMessage(LogEventLevel.Information, messageTemplate, null, destinationKey);
} }
/// <summary> /// <summary>
@ -202,8 +231,8 @@ namespace PepperDash.Essentials.Core
if (!signalType.HasFlag(eRoutingSignalType.AudioVideo) && if (!signalType.HasFlag(eRoutingSignalType.AudioVideo) &&
!(signalType.HasFlag(eRoutingSignalType.Video) && signalType.HasFlag(eRoutingSignalType.SecondaryAudio))) !(signalType.HasFlag(eRoutingSignalType.Video) && signalType.HasFlag(eRoutingSignalType.SecondaryAudio)))
{ {
var singleTypeRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, signalType); var singleTypeRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, signalType);
Debug.LogDebug(destination, "Attempting to build source route from {sourceKey} of type {type}", source.Key, signalType); Debug.LogMessage(LogEventLevel.Debug, "Attempting to build source route from {sourceKey} of type {type}", destination, source.Key, signalType);
if (!destination.GetRouteToSource(source, null, null, signalType, 0, singleTypeRouteDescriptor, destinationPort, sourcePort)) if (!destination.GetRouteToSource(source, null, null, signalType, 0, singleTypeRouteDescriptor, destinationPort, sourcePort))
singleTypeRouteDescriptor = null; singleTypeRouteDescriptor = null;
@ -211,46 +240,46 @@ namespace PepperDash.Essentials.Core
var routes = singleTypeRouteDescriptor?.Routes ?? new List<RouteSwitchDescriptor>(); var routes = singleTypeRouteDescriptor?.Routes ?? new List<RouteSwitchDescriptor>();
foreach (var route in routes) foreach (var route in routes)
{ {
Debug.LogVerbose(destination, "Route for device: {route}", route.ToString()); Debug.LogMessage(LogEventLevel.Verbose, "Route for device: {route}", destination, route.ToString());
} }
return (singleTypeRouteDescriptor, null); return (singleTypeRouteDescriptor, null);
} }
// otherwise, audioVideo needs to be handled as two steps. // otherwise, audioVideo needs to be handled as two steps.
Debug.LogDebug(destination, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", destination.Key, source.Key, signalType); Debug.LogMessage(LogEventLevel.Debug, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", destination, source.Key, signalType);
RouteDescriptor audioRouteDescriptor; RouteDescriptor audioRouteDescriptor;
if (signalType.HasFlag(eRoutingSignalType.SecondaryAudio)) if (signalType.HasFlag(eRoutingSignalType.SecondaryAudio))
{ {
audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.SecondaryAudio); audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.SecondaryAudio);
} }
else else
{ {
audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.Audio); audioRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.Audio);
} }
var audioSuccess = destination.GetRouteToSource(source, null, null, signalType.HasFlag(eRoutingSignalType.SecondaryAudio) ? eRoutingSignalType.SecondaryAudio : eRoutingSignalType.Audio, 0, audioRouteDescriptor, destinationPort, sourcePort); var audioSuccess = destination.GetRouteToSource(source, null, null, signalType.HasFlag(eRoutingSignalType.SecondaryAudio) ? eRoutingSignalType.SecondaryAudio : eRoutingSignalType.Audio, 0, audioRouteDescriptor, destinationPort, sourcePort);
if (!audioSuccess) if (!audioSuccess)
Debug.LogDebug(destination, "Cannot find audio route to {0}", source.Key); Debug.LogMessage(LogEventLevel.Debug, "Cannot find audio route to {0}", destination, source.Key);
var videoRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, sourcePort, eRoutingSignalType.Video); var videoRouteDescriptor = new RouteDescriptor(source, destination, destinationPort, eRoutingSignalType.Video);
var videoSuccess = destination.GetRouteToSource(source, null, null, eRoutingSignalType.Video, 0, videoRouteDescriptor, destinationPort, sourcePort); var videoSuccess = destination.GetRouteToSource(source, null, null, eRoutingSignalType.Video, 0, videoRouteDescriptor, destinationPort, sourcePort);
if (!videoSuccess) if (!videoSuccess)
Debug.LogDebug(destination, "Cannot find video route to {0}", source.Key); Debug.LogMessage(LogEventLevel.Debug, "Cannot find video route to {0}", destination, source.Key);
foreach (var route in audioRouteDescriptor.Routes) foreach (var route in audioRouteDescriptor.Routes)
{ {
Debug.LogVerbose(destination, "Audio route for device: {route}", route.ToString()); Debug.LogMessage(LogEventLevel.Verbose, "Audio route for device: {route}", destination, route.ToString());
} }
foreach (var route in videoRouteDescriptor.Routes) foreach (var route in videoRouteDescriptor.Routes)
{ {
Debug.LogVerbose(destination, "Video route for device: {route}", route.ToString()); Debug.LogMessage(LogEventLevel.Verbose, "Video route for device: {route}", destination, route.ToString());
} }
@ -275,8 +304,8 @@ namespace PepperDash.Essentials.Core
{ {
if (destination == null) throw new ArgumentNullException(nameof(destination)); if (destination == null) throw new ArgumentNullException(nameof(destination));
if (source == null) throw new ArgumentNullException(nameof(source)); if (source == null) throw new ArgumentNullException(nameof(source));
if (destinationPort == null) Debug.LogDebug("Destination port is null"); if (destinationPort == null) Debug.LogMessage(LogEventLevel.Information, "Destination port is null");
if (sourcePort == null) Debug.LogDebug("Source port is null"); if (sourcePort == null) Debug.LogMessage(LogEventLevel.Information, "Source port is null");
var routeRequest = new RouteRequest var routeRequest = new RouteRequest
{ {
@ -298,7 +327,7 @@ namespace PepperDash.Essentials.Core
RouteRequests[destination.Key] = routeRequest; RouteRequests[destination.Key] = routeRequest;
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); 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);
return; return;
} }
@ -310,7 +339,7 @@ namespace PepperDash.Essentials.Core
RouteRequests.Add(destination.Key, routeRequest); RouteRequests.Add(destination.Key, routeRequest);
Debug.LogInformation("Device: {destination} is cooling down. Storing route request to route to source key: {sourceKey}", destination.Key, routeRequest.Source.Key); Debug.LogMessage(LogEventLevel.Information, "Device: {destination} is cooling down. Storing route request to route to source key: {sourceKey}", null, destination.Key, routeRequest.Source.Key);
return; return;
} }
@ -322,7 +351,7 @@ namespace PepperDash.Essentials.Core
RouteRequests.Remove(destination.Key); RouteRequests.Remove(destination.Key);
Debug.LogInformation("Device: {destination} is NOT cooling down. Removing stored route request and routing to source key: {sourceKey}", destination.Key, routeRequest.Source.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);
} }
routeRequestQueue.Enqueue(new ReleaseRouteQueueItem(ReleaseRouteInternal, destination, destinationPort?.Key ?? string.Empty, false)); routeRequestQueue.Enqueue(new ReleaseRouteQueueItem(ReleaseRouteInternal, destination, destinationPort?.Key ?? string.Empty, false));
@ -343,10 +372,8 @@ namespace PepperDash.Essentials.Core
IndexTieLines(); IndexTieLines();
} }
var sinks = DeviceManager.AllDevices.OfType<IRoutingInputs>() var sinks = DeviceManager.AllDevices.OfType<IRoutingInputs>().Where(d => !(d is IRoutingInputsOutputs));
.Where(d => !(d is IRoutingInputsOutputs)).ToList(); var sources = DeviceManager.AllDevices.OfType<IRoutingOutputs>().Where(d => !(d is IRoutingInputsOutputs));
var sources = DeviceManager.AllDevices.OfType<IRoutingOutputs>()
.Where(d => !(d is IRoutingInputsOutputs)).ToList();
foreach (var sink in sinks) foreach (var sink in sinks)
{ {
@ -356,17 +383,13 @@ namespace PepperDash.Essentials.Core
{ {
foreach (var outputPort in source.OutputPorts) foreach (var outputPort in source.OutputPorts)
{ {
var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, outputPort.Type, inputPort, outputPort); var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, inputPort.Type, inputPort, outputPort);
if (audioOrSingleRoute == null && videoRoute == null) if (audioOrSingleRoute == null && videoRoute == null)
{ {
continue; continue;
} }
Debug.LogVerbose("AudioOrSingleRoute Found: {audioRoute}", audioOrSingleRoute);
Debug.LogVerbose("VideoRoute Found: {videoRoute}", videoRoute);
if (audioOrSingleRoute != null) if (audioOrSingleRoute != null)
{ {
// Only add routes that have actual switching steps // Only add routes that have actual switching steps
@ -375,10 +398,6 @@ namespace PepperDash.Essentials.Core
continue; 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 // 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 // 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)) if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Audio))
@ -410,10 +429,6 @@ namespace PepperDash.Essentials.Core
continue; continue;
} }
Debug.LogVerbose("Video route mapped: {source} -> {sink} via {input}/{output}",
source.Key, sink.Key,
inputPort.Key, outputPort.Key);
RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(videoRoute); RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(videoRoute);
} }
} }
@ -452,8 +467,7 @@ namespace PepperDash.Essentials.Core
audioOrSingleRoute = audioCollection.Descriptors.FirstOrDefault(d => audioOrSingleRoute = audioCollection.Descriptors.FirstOrDefault(d =>
d.Source.Key == request.Source.Key && d.Source.Key == request.Source.Key &&
d.Destination.Key == request.Destination.Key && d.Destination.Key == request.Destination.Key &&
(request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.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)) if (RouteDescriptors.TryGetValue(eRoutingSignalType.Video, out RouteDescriptorCollection videoCollection))
@ -461,8 +475,7 @@ namespace PepperDash.Essentials.Core
videoRoute = videoCollection.Descriptors.FirstOrDefault(d => videoRoute = videoCollection.Descriptors.FirstOrDefault(d =>
d.Source.Key == request.Source.Key && d.Source.Key == request.Source.Key &&
d.Destination.Key == request.Destination.Key && d.Destination.Key == request.Destination.Key &&
(request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key) && (request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.Key));
(request.SourcePort == null || d.OutputPort?.Key == request.SourcePort.Key));
} }
} }
else else
@ -477,15 +490,14 @@ namespace PepperDash.Essentials.Core
audioOrSingleRoute = collection.Descriptors.FirstOrDefault(d => audioOrSingleRoute = collection.Descriptors.FirstOrDefault(d =>
d.Source.Key == request.Source.Key && d.Source.Key == request.Source.Key &&
d.Destination.Key == request.Destination.Key && d.Destination.Key == request.Destination.Key &&
(request.DestinationPort == null || d.InputPort?.Key == request.DestinationPort.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 no pre-loaded route found, build it dynamically
if (audioOrSingleRoute == null && videoRoute == null) if (audioOrSingleRoute == null && videoRoute == null)
{ {
Debug.LogDebug(request.Destination, "No pre-loaded route found, building dynamically"); Debug.LogMessage(LogEventLevel.Debug, "No pre-loaded route found, building dynamically", request.Destination);
(audioOrSingleRoute, videoRoute) = request.Destination.GetRouteToSource(request.Source, request.SignalType, request.DestinationPort, request.SourcePort); (audioOrSingleRoute, videoRoute) = request.Destination.GetRouteToSource(request.Source, request.SignalType, request.DestinationPort, request.SourcePort);
} }
@ -499,15 +511,14 @@ namespace PepperDash.Essentials.Core
RouteDescriptorCollection.DefaultCollection.AddRouteDescriptor(videoRoute); RouteDescriptorCollection.DefaultCollection.AddRouteDescriptor(videoRoute);
} }
Debug.LogVerbose(request.Destination, "Executing full route"); Debug.LogMessage(LogEventLevel.Verbose, "Executing full route", request.Destination);
audioOrSingleRoute.ExecuteRoutes(); audioOrSingleRoute.ExecuteRoutes();
videoRoute?.ExecuteRoutes(); videoRoute?.ExecuteRoutes();
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogError("Exception Running Route Request {request}: {exception}", request, ex.Message); Debug.LogMessage(ex, "Exception Running Route Request {request}", null, request);
Debug.LogDebug(ex, "Stack Trace: ");
} }
} }
@ -521,7 +532,7 @@ namespace PepperDash.Essentials.Core
{ {
try try
{ {
Debug.LogInformation(destination, "Release route for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey); Debug.LogMessage(LogEventLevel.Information, "Release route for '{destination}':'{inputPortKey}'", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey);
if (RouteRequests.TryGetValue(destination.Key, out RouteRequest existingRequest) && destination is IWarmingCooling) if (RouteRequests.TryGetValue(destination.Key, out RouteRequest existingRequest) && destination is IWarmingCooling)
{ {
@ -535,14 +546,13 @@ namespace PepperDash.Essentials.Core
var current = RouteDescriptorCollection.DefaultCollection.RemoveRouteDescriptor(destination, inputPortKey); var current = RouteDescriptorCollection.DefaultCollection.RemoveRouteDescriptor(destination, inputPortKey);
if (current != null) if (current != null)
{ {
Debug.LogInformation(destination, "Releasing current route: {0}", current.Source.Key); Debug.LogMessage(LogEventLevel.Information, "Releasing current route: {0}", destination, current.Source.Key);
current.ReleaseRoutes(clearRoute); current.ReleaseRoutes(clearRoute);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogError("Exception releasing route for '{destination}':'{inputPortKey}': {exception}", destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey, ex.Message); Debug.LogMessage(ex, "Exception releasing route for '{destination}':'{inputPortKey}'", null, destination?.Key ?? null, string.IsNullOrEmpty(inputPortKey) ? "auto" : inputPortKey);
Debug.LogDebug(ex, "Stack Trace: ");
} }
} }
@ -567,7 +577,15 @@ namespace PepperDash.Essentials.Core
{ {
cycle++; cycle++;
Debug.LogVerbose("GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString()); // Check if this route has already been determined to be impossible
var routeKey = GetRouteKey(source.Key, destination.Key, signalType);
if (_impossibleRoutes.Contains(routeKey))
{
Debug.LogMessage(LogEventLevel.Verbose, "Route {0} is cached as impossible, skipping", null, routeKey);
return false;
}
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());
RoutingInputPort goodInputPort = null; RoutingInputPort goodInputPort = null;
@ -615,16 +633,12 @@ namespace PepperDash.Essentials.Core
} }
else // no direct-connect. Walk back devices. else // no direct-connect. Walk back devices.
{ {
Debug.LogVerbose(destination, "is not directly connected to {sourceKey}. Walking down tie lines", source.Key); Debug.LogMessage(LogEventLevel.Verbose, "is not directly connected to {sourceKey}. Walking down tie lines", destination, source.Key);
// No direct tie? Run back out on the inputs' attached devices... // No direct tie? Run back out on the inputs' attached devices...
// Only the ones that are routing devices // Only the ones that are routing devices
var midpointTieLines = destinationTieLines var midpointTieLines = destinationTieLines.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs);
.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 //Create a list for tracking already checked devices to avoid loops, if it doesn't already exist from previous iteration
if (alreadyCheckedDevices == null) if (alreadyCheckedDevices == null)
alreadyCheckedDevices = new List<IRoutingInputsOutputs>(); alreadyCheckedDevices = new List<IRoutingInputsOutputs>();
@ -637,13 +651,13 @@ namespace PepperDash.Essentials.Core
// Check if this previous device has already been walked // Check if this previous device has already been walked
if (alreadyCheckedDevices.Contains(midpointDevice)) if (alreadyCheckedDevices.Contains(midpointDevice))
{ {
Debug.LogVerbose(destination, "Skipping input {midpointDeviceKey} on {destinationKey}, this was already checked", midpointDevice.Key, destination.Key); Debug.LogMessage(LogEventLevel.Verbose, "Skipping input {midpointDeviceKey} on {destinationKey}, this was already checked", destination, midpointDevice.Key, destination.Key);
continue; continue;
} }
var midpointOutputPort = tieLine.SourcePort; var midpointOutputPort = tieLine.SourcePort;
Debug.LogVerbose(destination, "Trying to find route on {midpointDeviceKey}", midpointDevice.Key); Debug.LogMessage(LogEventLevel.Verbose, "Trying to find route on {midpointDeviceKey}", destination, midpointDevice.Key);
// haven't seen this device yet. Do it. Pass the output port to the next // haven't seen this device yet. Do it. Pass the output port to the next
// level to enable switching on success // level to enable switching on success
@ -652,9 +666,9 @@ namespace PepperDash.Essentials.Core
if (upstreamRoutingSuccess) if (upstreamRoutingSuccess)
{ {
Debug.LogVerbose(destination, "Upstream device route found"); Debug.LogMessage(LogEventLevel.Verbose, "Upstream device route found", destination);
Debug.LogVerbose(destination, "Route found on {midpointDeviceKey}", midpointDevice.Key); Debug.LogMessage(LogEventLevel.Verbose, "Route found on {midpointDeviceKey}", destination, midpointDevice.Key);
Debug.LogVerbose(destination, "TieLine: SourcePort: {SourcePort} DestinationPort: {DestinationPort}", tieLine.SourcePort, tieLine.DestinationPort); Debug.LogMessage(LogEventLevel.Verbose, "TieLine: SourcePort: {SourcePort} DestinationPort: {DestinationPort}", destination, tieLine.SourcePort, tieLine.DestinationPort);
goodInputPort = tieLine.DestinationPort; goodInputPort = tieLine.DestinationPort;
break; // Stop looping the inputs in this cycle break; // Stop looping the inputs in this cycle
} }
@ -664,7 +678,10 @@ namespace PepperDash.Essentials.Core
if (goodInputPort == null) if (goodInputPort == null)
{ {
Debug.LogVerbose(destination, "No route found to {0} from destination {1} for type {2}", source.Key, destination.Key, signalType); Debug.LogMessage(LogEventLevel.Verbose, "No route found to {0}", destination, source.Key);
// Cache this as an impossible route
_impossibleRoutes.Add(routeKey);
return false; return false;
} }
@ -681,7 +698,7 @@ namespace PepperDash.Essentials.Core
routeTable.Routes.Add(new RouteSwitchDescriptor(outputPortToUse, goodInputPort)); routeTable.Routes.Add(new RouteSwitchDescriptor(outputPortToUse, goodInputPort));
} }
else // device is merely IRoutingInputOutputs else // device is merely IRoutingInputOutputs
Debug.LogVerbose(destination, "No routing. Passthrough device"); Debug.LogMessage(LogEventLevel.Verbose, "No routing. Passthrough device", destination);
return true; return true;
} }

View file

@ -20,27 +20,22 @@ namespace PepperDash.Essentials.Core
public IRoutingInputs Destination { get; private set; } public IRoutingInputs Destination { get; private set; }
/// <summary> /// <summary>
/// The InputPort on the destination device for this route, if applicable. May be null if the route is not for a specific input port. /// Gets or sets the InputPort
/// </summary> /// </summary>
public RoutingInputPort InputPort { get; private set; } public RoutingInputPort InputPort { get; private set; }
/// <summary> /// <summary>
/// Gets the source device (sink or midpoint) for the route. /// Gets or sets the Source
/// </summary> /// </summary>
public IRoutingOutputs Source { get; private set; } public IRoutingOutputs Source { get; private set; }
/// <summary> /// <summary>
/// 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. /// Gets or sets the SignalType
/// </summary>
public RoutingOutputPort OutputPort { get; private set; }
/// <summary>
/// Gets the signal type for this route.
/// </summary> /// </summary>
public eRoutingSignalType SignalType { get; private set; } public eRoutingSignalType SignalType { get; private set; }
/// <summary> /// <summary>
/// Gets the collection of route switch descriptors for this route. /// Gets or sets the Routes
/// </summary> /// </summary>
public List<RouteSwitchDescriptor> Routes { get; private set; } public List<RouteSwitchDescriptor> Routes { get; private set; }
@ -61,24 +56,11 @@ namespace PepperDash.Essentials.Core
/// <param name="destination">The destination device.</param> /// <param name="destination">The destination device.</param>
/// <param name="inputPort">The destination input port (optional).</param> /// <param name="inputPort">The destination input port (optional).</param>
/// <param name="signalType">The signal type for this route.</param> /// <param name="signalType">The signal type for this route.</param>
public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, eRoutingSignalType signalType) : this(source, destination, inputPort, null, signalType) public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, eRoutingSignalType signalType)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RouteDescriptor"/> class for a route with specific destination input and source output ports.
/// </summary>
/// <param name="source"></param>
/// <param name="destination"></param>
/// <param name="inputPort"></param>
/// <param name="outputPort"></param>
/// <param name="signalType"></param>
public RouteDescriptor(IRoutingOutputs source, IRoutingInputs destination, RoutingInputPort inputPort, RoutingOutputPort outputPort, eRoutingSignalType signalType)
{ {
Destination = destination; Destination = destination;
InputPort = inputPort; InputPort = inputPort;
Source = source; Source = source;
OutputPort = outputPort;
SignalType = signalType; SignalType = signalType;
Routes = new List<RouteSwitchDescriptor>(); Routes = new List<RouteSwitchDescriptor>();
} }
@ -90,7 +72,7 @@ namespace PepperDash.Essentials.Core
{ {
foreach (var route in Routes) foreach (var route in Routes)
{ {
Debug.LogVerbose("ExecuteRoutes: {0}", route.ToString()); Debug.LogMessage(LogEventLevel.Verbose, "ExecuteRoutes: {0}", null, route.ToString());
if (route.SwitchingDevice is IRoutingSinkWithSwitching sink) if (route.SwitchingDevice is IRoutingSinkWithSwitching sink)
{ {
@ -104,7 +86,7 @@ namespace PepperDash.Essentials.Core
route.OutputPort.InUseTracker.AddUser(Destination, "destination-" + SignalType); route.OutputPort.InUseTracker.AddUser(Destination, "destination-" + SignalType);
Debug.LogVerbose("Output port {0} routing. Count={1}", route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); Debug.LogMessage(LogEventLevel.Verbose, "Output port {0} routing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue);
} }
} }
} }
@ -130,7 +112,6 @@ namespace PepperDash.Essentials.Core
catch (Exception e) catch (Exception e)
{ {
Debug.LogError("Error executing switch: {exception}", e.Message); Debug.LogError("Error executing switch: {exception}", e.Message);
Debug.LogDebug(e, "Stack Trace: ");
} }
} }
@ -142,11 +123,11 @@ namespace PepperDash.Essentials.Core
if (route.OutputPort.InUseTracker != null) if (route.OutputPort.InUseTracker != null)
{ {
route.OutputPort.InUseTracker.RemoveUser(Destination, "destination-" + SignalType); route.OutputPort.InUseTracker.RemoveUser(Destination, "destination-" + SignalType);
Debug.LogVerbose("Port {0} releasing. Count={1}", route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue); Debug.LogMessage(LogEventLevel.Verbose, "Port {0} releasing. Count={1}", null, route.OutputPort.Key, route.OutputPort.InUseTracker.InUseCountFeedback.UShortValue);
} }
else else
{ {
Debug.LogVerbose("InUseTracker is null for OutputPort {0}", route.OutputPort.Key); Debug.LogMessage(LogEventLevel.Error, "InUseTracker is null for OutputPort {0}", null, route.OutputPort.Key);
} }
} }
} }

View file

@ -51,25 +51,20 @@ namespace PepperDash.Essentials.Core
t.Destination == descriptor.Destination && t.Destination == descriptor.Destination &&
t.SignalType == descriptor.SignalType && t.SignalType == descriptor.SignalType &&
((t.InputPort == null && descriptor.InputPort == null) || ((t.InputPort == null && descriptor.InputPort == null) ||
(t.InputPort != null && descriptor.InputPort != null && t.InputPort.Key == descriptor.InputPort.Key)) && (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) if (existingRoute != null)
{ {
Debug.LogInformation(descriptor.Destination, Debug.LogMessage(LogEventLevel.Information, descriptor.Destination,
"Route from {source}:{outputPort} to {destination}:{inputPort} ({signalType}) already exists in this collection", "Route from {0} to {1}:{2} ({3}) already exists in this collection",
descriptor?.Source?.Key, descriptor?.Source?.Key,
descriptor?.OutputPort?.Key ?? "auto",
descriptor?.Destination?.Key, descriptor?.Destination?.Key,
descriptor?.InputPort?.Key ?? "auto", descriptor?.InputPort?.Key ?? "auto",
descriptor?.SignalType descriptor?.SignalType);
);
return; return;
} }
Debug.LogVerbose("Adding route descriptor: {source}:{outputPort} -> {destination}:{inputPort} ({signalType})", Debug.LogMessage(LogEventLevel.Verbose, "Adding route descriptor: {0} -> {1}:{2} ({3})",
descriptor?.Source?.Key, descriptor?.Source?.Key,
descriptor?.OutputPort?.Key ?? "auto",
descriptor?.Destination?.Key, descriptor?.Destination?.Key,
descriptor?.InputPort?.Key ?? "auto", descriptor?.InputPort?.Key ?? "auto",
descriptor?.SignalType); descriptor?.SignalType);

View file

@ -1,7 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Timers; using Crestron.SimplSharp;
using PepperDash.Core; using PepperDash.Core;
using PepperDash.Essentials.Core.Config; using PepperDash.Essentials.Core.Config;
@ -21,12 +21,7 @@ namespace PepperDash.Essentials.Core.Routing
/// <summary> /// <summary>
/// Debounce timers for each sink device to prevent rapid successive updates /// Debounce timers for each sink device to prevent rapid successive updates
/// </summary> /// </summary>
private readonly Dictionary<string, Timer> updateTimers = new Dictionary<string, Timer>(); private readonly Dictionary<string, CTimer> updateTimers = new Dictionary<string, CTimer>();
/// <summary>
/// Lock object protecting all access to <see cref="updateTimers"/>.
/// </summary>
private readonly object _timerLock = new object();
/// <summary> /// <summary>
/// Debounce delay in milliseconds /// Debounce delay in milliseconds
@ -55,6 +50,7 @@ namespace PepperDash.Essentials.Core.Routing
midpointToSinksMap = new Dictionary<string, HashSet<string>>(); midpointToSinksMap = new Dictionary<string, HashSet<string>>();
var sinks = DeviceManager.AllDevices.OfType<IRoutingSinkWithSwitchingWithInputPort>(); var sinks = DeviceManager.AllDevices.OfType<IRoutingSinkWithSwitchingWithInputPort>();
var midpoints = DeviceManager.AllDevices.OfType<IRoutingWithFeedback>();
foreach (var sink in sinks) foreach (var sink in sinks)
{ {
@ -66,9 +62,6 @@ namespace PepperDash.Essentials.Core.Routing
foreach (var midpointKey in upstreamMidpoints) foreach (var midpointKey in upstreamMidpoints)
{ {
if (string.IsNullOrEmpty(midpointKey))
continue;
if (!midpointToSinksMap.ContainsKey(midpointKey)) if (!midpointToSinksMap.ContainsKey(midpointKey))
midpointToSinksMap[midpointKey] = new HashSet<string>(); midpointToSinksMap[midpointKey] = new HashSet<string>();
@ -118,8 +111,7 @@ namespace PepperDash.Essentials.Core.Routing
if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint) if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint)
{ {
if (!string.IsNullOrEmpty(midpoint.Key)) midpoints.Add(midpoint.Key);
midpoints.Add(midpoint.Key);
// Find upstream TieLines connected to this midpoint's inputs // Find upstream TieLines connected to this midpoint's inputs
var midpointInputs = (midpoint as IRoutingInputs)?.InputPorts; var midpointInputs = (midpoint as IRoutingInputs)?.InputPorts;
@ -219,49 +211,9 @@ namespace PepperDash.Essentials.Core.Routing
} }
} }
/// <summary>
/// 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.
/// </summary>
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<string>();
midpointToSinksMap[midpointKey].Add(sink.Key);
}
}
/// <summary> /// <summary>
/// Handles the InputChanged event from a sink device. /// Handles the InputChanged event from a sink device.
/// Updates the midpoint-to-sink map for the new input path, then triggers /// Triggers an update for the specific sink device.
/// a source-info update for the sink.
/// </summary> /// </summary>
/// <param name="sender">The sink device that reported an input change.</param> /// <param name="sender">The sink device that reported an input change.</param>
/// <param name="currentInputPort">The new input port selected on the sink device.</param> /// <param name="currentInputPort">The new input port selected on the sink device.</param>
@ -272,10 +224,6 @@ namespace PepperDash.Essentials.Core.Routing
{ {
try try
{ {
// Keep the map current so HandleMidpointUpdate can find this sink
if (sender is IRoutingSinkWithSwitchingWithInputPort sinkWithInputPort)
RebuildMapForSink(sinkWithInputPort);
UpdateDestination(sender, currentInputPort); UpdateDestination(sender, currentInputPort);
} }
catch (Exception ex) catch (Exception ex)
@ -307,13 +255,15 @@ namespace PepperDash.Essentials.Core.Routing
var key = destination.Key; var key = destination.Key;
// Cancel and replace any existing timer under the lock so no callback // Cancel existing timer for this sink
// can race with us while we swap the entry. if (updateTimers.TryGetValue(key, out var existingTimer))
Timer timerToDispose = null; {
Timer newTimer = null; existingTimer.Stop();
existingTimer.Dispose();
}
newTimer = new Timer(DEBOUNCE_MS) { AutoReset = false }; // Start new debounced timer
newTimer.Elapsed += (s, e) => updateTimers[key] = new CTimer(_ =>
{ {
try try
{ {
@ -331,36 +281,13 @@ namespace PepperDash.Essentials.Core.Routing
} }
finally finally
{ {
// Remove the entry first so a concurrent UpdateDestination call if (updateTimers.ContainsKey(key))
// 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)) updateTimers[key]?.Dispose();
{ updateTimers.Remove(key);
selfTimer = current;
updateTimers.Remove(key);
}
} }
selfTimer?.Dispose();
} }
}; }, null, DEBOUNCE_MS);
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();
} }
/// <summary> /// <summary>
@ -457,8 +384,7 @@ namespace PepperDash.Essentials.Core.Routing
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogError(this, "Error getting sourceTieLine: {message}", ex.Message); Debug.LogMessage(ex, "Error getting sourceTieLine: {Exception}", this, ex);
Debug.LogDebug(ex, "StackTrace: ");
return; return;
} }
@ -482,11 +408,6 @@ namespace PepperDash.Essentials.Core.Routing
return roomDefaultDisplay.DefaultDisplay.Key == destination.Key; return roomDefaultDisplay.DefaultDisplay.Key == destination.Key;
} }
if (ConfigReader.ConfigObject.GetDestinationListForKey(r.DestinationListKey)?.FirstOrDefault(d => d.Value.SinkKey == destination.Key) != null)
{
return true;
}
return false; return false;
} }
); );
@ -508,8 +429,10 @@ namespace PepperDash.Essentials.Core.Routing
if (sourceList == null) if (sourceList == null)
{ {
Debug.LogDebug(this, Debug.LogMessage(
Serilog.Events.LogEventLevel.Debug,
"No source list found for source list key {key}. Unable to find source for tieLine {sourceTieLine}", "No source list found for source list key {key}. Unable to find source for tieLine {sourceTieLine}",
this,
room.SourceListKey, room.SourceListKey,
sourceTieLine sourceTieLine
); );
@ -538,8 +461,10 @@ namespace PepperDash.Essentials.Core.Routing
if (source == null) if (source == null)
{ {
Debug.LogDebug(this, Debug.LogMessage(
Serilog.Events.LogEventLevel.Debug,
"No source found for device {key}. Creating transient source for {destination}", "No source found for device {key}. Creating transient source for {destination}",
this,
sourceTieLine.SourcePort.ParentDevice.Key, sourceTieLine.SourcePort.ParentDevice.Key,
destination destination
); );
@ -573,8 +498,10 @@ namespace PepperDash.Essentials.Core.Routing
{ {
if (!(tieLine.DestinationPort.ParentDevice is IRoutingInputs sink)) if (!(tieLine.DestinationPort.ParentDevice is IRoutingInputs sink))
{ {
Debug.LogDebug(this, Debug.LogMessage(
Serilog.Events.LogEventLevel.Debug,
"TieLine destination {device} is not IRoutingInputs", "TieLine destination {device} is not IRoutingInputs",
this,
tieLine.DestinationPort.ParentDevice.Key tieLine.DestinationPort.ParentDevice.Key
); );
return null; return null;
@ -613,21 +540,17 @@ namespace PepperDash.Essentials.Core.Routing
if (route != null && route.Routes != null && route.Routes.Count > 0) if (route != null && route.Routes != null && route.Routes.Count > 0)
{ {
// Routes[0] is the hop nearest the source: its InputPort is the // Found a valid route - return the source TieLine
// 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 => var sourceTieLine = TieLineCollection.Default.FirstOrDefault(tl =>
tl.DestinationPort.Key == firstHop.InputPort.Key && tl.SourcePort.ParentDevice.Key == source.Key &&
tl.DestinationPort.ParentDevice.Key == firstHop.InputPort.ParentDevice.Key); tl.Type.HasFlag(signalType));
if (sourceTieLine != null) if (sourceTieLine != null)
{ {
Debug.LogDebug(this, Debug.LogMessage(
"Found route from {source} to {sink} with {count} hops", Serilog.Events.LogEventLevel.Debug,
"Found route from {source} to {sink} with {count} hops",
this,
source.Key, source.Key,
sink.Key, sink.Key,
route.Routes.Count route.Routes.Count
@ -638,14 +561,22 @@ namespace PepperDash.Essentials.Core.Routing
} }
} }
Debug.LogDebug(this, "No route found to any source from {sink}", sink.Key); Debug.LogMessage(
Serilog.Events.LogEventLevel.Debug,
"No route found to any source from {sink}",
this,
sink.Key
);
return null; return null;
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogError(this, "Error getting root tieLine: {message}", ex.Message); Debug.LogMessage(
Debug.LogDebug(ex, "StackTrace: "); ex,
"Error getting root tieLine: {Exception}",
this,
ex
);
return null; return null;
} }
} }

View file

@ -27,19 +27,16 @@ namespace PepperDash.Essentials.Core
/// <summary> /// <summary>
/// Control signal type /// Control signal type
/// </summary> /// </summary>
[Obsolete("UsbOutput is no longer supported and will be removed in a future release.")]
UsbOutput = 8, UsbOutput = 8,
/// <summary> /// <summary>
/// Control signal type /// Control signal type
/// </summary> /// </summary>
[Obsolete("UsbInput is no longer supported and will be removed in a future release.")]
UsbInput = 16, UsbInput = 16,
/// <summary> /// <summary>
/// Secondary audio signal type /// Secondary audio signal type
/// </summary> /// </summary>
[Obsolete("SecondaryAudio is no longer supported and will be removed in a future release.")]
SecondaryAudio = 32 SecondaryAudio = 32
} }
} }

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using Crestron.SimplSharp; using Crestron.SimplSharp;
using PepperDash.Core; using PepperDash.Core;
using PepperDash.Core.Logging;
using Serilog.Events; using Serilog.Events;
namespace PepperDash.Essentials.Core namespace PepperDash.Essentials.Core
@ -297,14 +296,19 @@ namespace PepperDash.Essentials.Core
{ {
var secretPresent = provider.TestSecret(key); var secretPresent = provider.TestSecret(key);
provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
if (!secretPresent) if (!secretPresent)
return return
$"Unable to update secret for {provider.Key}:{key} - Please use the 'SetSecret' command to modify it"; String.Format(
"Unable to update secret for {0}:{1} - Please use the 'SetSecret' command to modify it");
var response = provider.SetSecret(key, secret) var response = provider.SetSecret(key, secret)
? $"Secret successfully set for {provider.Key}:{key}" ? String.Format(
: $"Unable to set secret for {provider.Key}:{key}"; "Secret successfully set for {0}:{1}",
provider.Key, key)
: String.Format(
"Unable to set secret for {0}:{1}",
provider.Key, key);
return response; return response;
} }
@ -312,14 +316,19 @@ namespace PepperDash.Essentials.Core
{ {
var secretPresent = provider.TestSecret(key); var secretPresent = provider.TestSecret(key);
provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
if (secretPresent) if (secretPresent)
return return
$"Unable to set secret for {provider.Key}:{key} - Please use the 'UpdateSecret' command to modify it"; String.Format(
"Unable to set secret for {0}:{1} - Please use the 'UpdateSecret' command to modify it");
var response = provider.SetSecret(key, secret) var response = provider.SetSecret(key, secret)
? $"Secret successfully set for {provider.Key}:{key}" ? String.Format(
: $"Unable to set secret for {provider.Key}:{key}"; "Secret successfully set for {0}:{1}",
provider.Key, key)
: String.Format(
"Unable to set secret for {0}:{1}",
provider.Key, key);
return response; return response;
} }
@ -368,10 +377,15 @@ namespace PepperDash.Essentials.Core
var key = args[1]; var key = args[1];
provider.SetSecret(key, ""); provider.SetSecret(key, "");
response = provider.SetSecret(key, "") response = provider.SetSecret(key, "")
? $"Secret successfully deleted for {provider.Key}:{key}" ? String.Format(
: $"Unable to delete secret for {provider.Key}:{key}"; "Secret successfully deleted for {0}:{1}",
provider.Key, key)
: String.Format(
"Unable to delete secret for {0}:{1}",
provider.Key, key);
CrestronConsole.ConsoleCommandResponse(response); CrestronConsole.ConsoleCommandResponse(response);
return; return;

View file

@ -17,11 +17,6 @@ namespace PepperDash.Essentials.Core.Web
{ {
private readonly WebApiServer _server; private readonly WebApiServer _server;
private readonly WebApiServer _debugServer;
///<example> ///<example>
/// http(s)://{ipaddress}/cws/{basePath} /// http(s)://{ipaddress}/cws/{basePath}
/// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath}
@ -75,9 +70,6 @@ namespace PepperDash.Essentials.Core.Web
_server = new WebApiServer(Key, Name, BasePath); _server = new WebApiServer(Key, Name, BasePath);
_debugServer = new WebApiServer($"{key}-debug-app", $"{name} Debug App", "/debug");
_debugServer.SetFallbackHandler(new ServeDebugAppRequestHandler());
SetupRoutes(); SetupRoutes();
} }
@ -85,21 +77,11 @@ namespace PepperDash.Essentials.Core.Web
{ {
var routes = new List<HttpCwsRoute> var routes = new List<HttpCwsRoute>
{ {
new HttpCwsRoute("login")
{
Name = "Root",
RouteHandler = new LoginRequestHandler()
},
new HttpCwsRoute("versions") new HttpCwsRoute("versions")
{ {
Name = "ReportVersions", Name = "ReportVersions",
RouteHandler = new ReportVersionsRequestHandler() RouteHandler = new ReportVersionsRequestHandler()
}, },
new HttpCwsRoute("packageManifest")
{
Name = "GetPackageManifest",
RouteHandler = new GetPackageManifestRequestHandler()
},
new HttpCwsRoute("appdebug") new HttpCwsRoute("appdebug")
{ {
Name = "AppDebug", Name = "AppDebug",
@ -255,7 +237,6 @@ namespace PepperDash.Essentials.Core.Web
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series"); Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series");
_server.Start(); _server.Start();
_debugServer.Start();
GetPaths(); GetPaths();
@ -266,13 +247,12 @@ namespace PepperDash.Essentials.Core.Web
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server"); Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server");
_server.Start(); _server.Start();
_debugServer.Start();
GetPaths(); GetPaths();
} }
/// <summary> /// <summary>
/// Print the available paths /// Print the available pahts
/// </summary> /// </summary>
/// <example> /// <example>
/// http(s)://{ipaddress}/cws/{basePath} /// http(s)://{ipaddress}/cws/{basePath}
@ -308,15 +288,7 @@ namespace PepperDash.Essentials.Core.Web
{ {
Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url); 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)); 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);
} }
} }
} }

View file

@ -36,25 +36,17 @@ namespace PepperDash.Essentials.Core.Web
}; };
} }
/// <summary> /// <summary>
/// MapToDeviceListObject method /// MapToDeviceListObject method
/// </summary> /// </summary>
public static object MapToDeviceListObject(IKeyed device) public static object MapToDeviceListObject(IKeyed device)
{ {
var interfaces = device.GetType()
.GetInterfaces()
.Select(i => i.Name)
.ToList();
return new return new
{ {
device.Key, device.Key,
Name = (device is IKeyName) Name = (device is IKeyName)
? (device as IKeyName).Name ? (device as IKeyName).Name
: "---", : "---"
Interfaces = interfaces
}; };
} }
@ -118,6 +110,5 @@ namespace PepperDash.Essentials.Core.Web
CType = device.Value.Type == null ? "---": device.Value.Type.ToString() CType = device.Value.Type == null ? "---": device.Value.Type.ToString()
}; };
} }
}
}
} }

View file

@ -17,10 +17,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
/// Represents a DebugSessionRequestHandler /// Represents a DebugSessionRequestHandler
/// </summary> /// </summary>
public class DebugSessionRequestHandler : WebApiBaseRequestHandler public class DebugSessionRequestHandler : WebApiBaseRequestHandler
{ {
private CTimer _portForwardTimeoutTimer;
private readonly object _timerLock = new object();
/// <summary> /// <summary>
/// Constructor /// Constructor
/// </summary> /// </summary>
@ -51,7 +48,6 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
var port = 0; var port = 0;
string csIp = null;
if (!Debug.WebsocketSink.IsRunning) if (!Debug.WebsocketSink.IsRunning)
{ {
@ -63,50 +59,14 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose); Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose);
} }
// Attempt to get the CS LAN IP and forward the port
try
{
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(
EthernetAdapterType.EthernetCSAdapter);
csIp = CrestronEthernetHelper.GetEthernetParameter(
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
if (port > 0)
{
var result = CrestronEthernetHelper.AddPortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error 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; var url = Debug.WebsocketSink.Url;
var data = new object data = new
{ {
url = Debug.WebsocketSink.Url, url = Debug.WebsocketSink.Url
fallbackUrl = csIp != null ? url.Replace(csIp, ip) : null
}; };
Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url); Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url);
Debug.LogMessage(LogEventLevel.Information, "Fallback Debug Session URL: {0}", data.fallbackUrl);
// Return the port number with the full url of the WS Server // Return the port number with the full url of the WS Server
var res = JsonConvert.SerializeObject(data); var res = JsonConvert.SerializeObject(data);
@ -130,49 +90,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
/// <param name="context"></param> /// <param name="context"></param>
protected override void HandlePost(HttpCwsContext context) protected override void HandlePost(HttpCwsContext context)
{ {
CancelPortForwardTimeout();
var port = Debug.WebsocketSink.Port;
Debug.WebsocketSink.StopServer(); 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.StatusCode = 200;
context.Response.StatusDescription = "OK"; context.Response.StatusDescription = "OK";
context.Response.End(); context.Response.End();
@ -180,55 +99,5 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped"); Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped");
} }
private void StartPortForwardTimeout(int port, string csIp)
{
lock (_timerLock)
{
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = new CTimer(_ =>
{
if (Debug.WebsocketSink.HasActiveConnections)
{
Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward");
return;
}
Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port);
try
{
var result = CrestronEthernetHelper.RemovePortForwarding(
(ushort)port, (ushort)port, csIp,
CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", result);
}
else
{
Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed due to timeout", port);
}
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", ex.Message);
}
}, 30000);
}
}
/// <summary>
/// Cancels the port forward timeout timer if a session is being explicitly stopped.
/// </summary>
private void CancelPortForwardTimeout()
{
lock (_timerLock)
{
_portForwardTimeoutTimer?.Dispose();
_portForwardTimeoutTimer = null;
}
}
} }
} }

View file

@ -1,14 +1,13 @@
using System; using System.Linq;
using System.Linq;
using Crestron.SimplSharp.WebScripting; using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json; using Newtonsoft.Json;
using PepperDash.Core.Web.RequestHandlers; using PepperDash.Core.Web.RequestHandlers;
namespace PepperDash.Essentials.Core.Web.RequestHandlers namespace PepperDash.Essentials.Core.Web.RequestHandlers
{ {
/// <summary> /// <summary>
/// Represents a GetFeedbacksForDeviceRequestHandler /// Represents a GetFeedbacksForDeviceRequestHandler
/// </summary> /// </summary>
public class GetFeedbacksForDeviceRequestHandler : WebApiBaseRequestHandler public class GetFeedbacksForDeviceRequestHandler : WebApiBaseRequestHandler
{ {
/// <summary> /// <summary>
@ -77,7 +76,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
Value = feedback.IntValue Value = feedback.IntValue
}; };
var stringFeedback = var stringFeedback =
from feedback in device.Feedbacks.OfType<StringFeedback>() from feedback in device.Feedbacks.OfType<StringFeedback>()
where !string.IsNullOrEmpty(feedback.Key) where !string.IsNullOrEmpty(feedback.Key)
select new select new

View file

@ -1,259 +0,0 @@
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
{
/// <summary>
/// Represents a GetPackageManifestRequestHandler
/// </summary>
public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler
{
/// <summary>
/// Constructor
/// </summary>
/// <remarks>
/// base(true) enables CORS support by default
/// </remarks>
public GetPackageManifestRequestHandler()
: base(true)
{
}
/// <summary>
/// Handles GET method requests
/// </summary>
/// <param name="context"></param>
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();
}
}
/// <summary>
/// Deep-copies the config's VersionData so the live config object is never mutated
/// </summary>
private static VersionData CloneVersionData(VersionData source)
{
if (source == null)
{
return null;
}
var json = JsonConvert.SerializeObject(source);
return JsonConvert.DeserializeObject<VersionData>(json);
}
/// <summary>
/// Enriches (or creates) the essentials entry from the loaded PepperDash.Essentials.Core assembly
/// </summary>
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;
}
/// <summary>
/// Merges reflection data from loaded plugin assemblies with the config's packages list
/// </summary>
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<NugetVersion>())
.Where(p => p != null)
.ToList();
var matchedConfigPackages = new System.Collections.Generic.HashSet<NugetVersion>();
var mergedPackages = new System.Collections.Generic.List<NugetVersion>();
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
// <AssemblyMetadata Include="PackageId" Value="$(PackageId)" /> 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<AssemblyMetadataAttribute>()
.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);
}
}
}

View file

@ -1,173 +0,0 @@
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
{
/// <summary>
/// Represents a LoginRequestHandler
/// </summary>
public class LoginRequestHandler : WebApiBaseRequestHandler
{
/// <summary>
/// Constructor
/// </summary>
/// <remarks>
/// base(true) enables CORS support by default
/// </remarks>
public LoginRequestHandler()
: base(true)
{
}
/// <summary>
/// Handles POST method requests for user login and token generation
/// </summary>
/// <param name="context">The HTTP context for the request.</param>
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<LoginRequest>(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();
}
}
}
/// <summary>
/// Represents a LoginRequest
/// </summary>
public class LoginRequest
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public string Password { get; set; }
}
/// <summary>
/// Represents a LoginResponse
/// </summary>
internal class LoginResponse
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public string UserName { get; set; }
/// <summary>
/// Gets or sets the access level.
/// </summary>
public Authentication.UserAuthenticationLevelEnum Access { get; set; }
/// <summary>
/// Gets or sets the token authenticated state.
/// </summary>
public Authentication.eTokenAuthenticatedState State { get; set; }
/// <summary>
/// Gets or sets the list of groups.
/// </summary>
public List<string> Groups { get; set; }
/// <summary>
/// Gets or sets the active directory connection flag.
/// </summary>
public int ADConnect { get; set; }
/// <summary>
/// Gets or sets the valid flag indicating whether the token is valid.
/// </summary>
public bool Valid { get; set; }
}
}

View file

@ -1,224 +0,0 @@
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
{
/// <summary>
/// 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.
/// </summary>
public class ServeDebugAppRequestHandler : WebApiBaseRequestHandler
{
private static readonly Dictionary<string, string> MimeTypes = new Dictionary<string, string>(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<string> TextExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".html", ".htm", ".js", ".mjs", ".jsx", ".css", ".json", ".map", ".svg", ".txt", ".xml"
};
/// <summary>
/// Constructor. CORS is enabled so browser dev-tools requests succeed.
/// </summary>
public ServeDebugAppRequestHandler() : base(true) { }
/// <summary>
/// Handles GET requests for the debug app and its static assets.
/// </summary>
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 */ }
}
}
/// <summary>
/// Resolves the absolute path of the /HTML/debug folder on the processor.
/// </summary>
/// <remarks>
/// <c>Global.FilePathPrefix</c> is always <c>{root}/user/programX/</c> (or
/// equivalent), so walking up two parents gives the processor root that
/// contains the <c>html</c> folder. This mirrors the two-hop strategy used
/// by <c>AssetLoader.ExtractDevToolsZip</c> so that serving and extraction
/// always resolve to the same directory.
/// </remarks>
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;
}
}
/// <summary>
/// Extracts the file sub-path from the request by parsing <c>RawUrl</c>.
/// Returns an empty string when the URL ends at <c>/debug</c> (root hit).
/// </summary>
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();
}
}
}

View file

@ -17,8 +17,6 @@ namespace PepperDash.Essentials.Devices.Common.Cameras
/// <summary> /// <summary>
/// Represents a CameraVisca /// Represents a CameraVisca
/// </summary> /// </summary>
[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 public class CameraVisca : CameraBase, IHasCameraPtzControl, ICommunicationMonitor, IHasCameraPresets, IHasPowerControlWithFeedback, IBridgeAdvanced, IHasCameraFocusControl, IHasAutoFocusMode
{ {
private readonly CameraViscaPropertiesConfig PropertiesConfig; private readonly CameraViscaPropertiesConfig PropertiesConfig;

View file

@ -152,15 +152,6 @@ namespace PepperDash.Essentials.Devices.Common.Shades
private void IsCoolingDownFeedback_OutputChange(object sender, FeedbackEventArgs e) 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 ( if (
!DisplayDevice.IsCoolingDownFeedback.BoolValue !DisplayDevice.IsCoolingDownFeedback.BoolValue
&& Type == eScreenLiftControlType.lift && Type == eScreenLiftControlType.lift
@ -183,15 +174,6 @@ namespace PepperDash.Essentials.Devices.Common.Shades
{ {
if (DisplayDevice.IsWarmingUpFeedback.BoolValue) 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(); Lower();
} }
} }

View file

@ -41,23 +41,5 @@ namespace PepperDash.Essentials.Devices.Common.Shades
/// </summary> /// </summary>
[JsonProperty("muteOnScreenUp")] [JsonProperty("muteOnScreenUp")]
public bool MuteOnScreenUp { get; set; } public bool MuteOnScreenUp { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonProperty("disableAutoLowerOnPowerOn")]
public bool DisableAutoLowerOnPowerOn { get; set; }
/// <summary>
/// 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 <see cref="DisableAutoLowerOnPowerOn"/>; together they make a controller fully
/// manual while leaving other controllers (e.g. the lift) on their default automatic behavior.
/// </summary>
[JsonProperty("disableAutoRaiseOnPowerOff")]
public bool DisableAutoRaiseOnPowerOff { get; set; }
} }
} }

View file

@ -29,6 +29,6 @@
<ProjectReference Include="..\PepperDash.Essentials.Core\PepperDash.Essentials.Core.csproj" /> <ProjectReference Include="..\PepperDash.Essentials.Core\PepperDash.Essentials.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -21,7 +21,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec
/// <summary> /// <summary>
/// Represents a MockVC /// Represents a MockVC
/// </summary> /// </summary>
public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets, IRoutingInputs public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets
{ {
/// <summary> /// <summary>
/// Gets or sets the PropertiesConfig /// Gets or sets the PropertiesConfig

View file

@ -26,7 +26,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec
/// <summary> /// <summary>
/// Base class for video codec devices /// Base class for video codec devices
/// </summary> /// </summary>
public abstract class VideoCodecBase : ReconfigurableDevice, public abstract class VideoCodecBase : ReconfigurableDevice, IRoutingInputsOutputs,
IUsageTracking, IHasDialer, IHasContentSharing, ICodecAudio, iVideoCodecInfo, IBridgeAdvanced, IHasStandbyMode IUsageTracking, IHasDialer, IHasContentSharing, ICodecAudio, iVideoCodecInfo, IBridgeAdvanced, IHasStandbyMode
{ {
private const int XSigEncoding = 28591; private const int XSigEncoding = 28591;

View file

@ -10,7 +10,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// <summary> /// <summary>
/// The device key /// The device key
/// </summary> /// </summary>
[JsonProperty("key", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("key")]
/// <summary> /// <summary>
/// Gets or sets the Key /// Gets or sets the Key
/// </summary> /// </summary>
@ -19,19 +19,19 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// <summary> /// <summary>
/// The device name /// The device name
/// </summary> /// </summary>
[JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("name")]
public string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// The type of the message class /// The type of the message class
/// </summary> /// </summary>
[JsonProperty("messageType", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("messageType")]
public string MessageType => GetType().Name; public string MessageType => GetType().Name;
/// <summary> /// <summary>
/// Gets or sets the MessageBasePath /// Gets or sets the MessageBasePath
/// </summary> /// </summary>
[JsonProperty("messageBasePath", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("messageBasePath")]
public string MessageBasePath { get; set; } public string MessageBasePath { get; set; }
} }

View file

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -12,15 +11,13 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// <summary> /// <summary>
/// The interfaces implmented by the device sending the messsage /// The interfaces implmented by the device sending the messsage
/// </summary> /// </summary>
[JsonProperty("interfaces", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("interfaces")]
[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<string> Interfaces { get; private set; } public List<string> Interfaces { get; private set; }
/// <summary> /// <summary>
/// Sets the interfaces implemented by the device sending the message /// Sets the interfaces implemented by the device sending the message
/// </summary> /// </summary>
/// <param name="interfaces"></param> /// <param name="interfaces"></param>
[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<string> interfaces) public void SetInterfaces(List<string> interfaces)
{ {
Interfaces = interfaces; Interfaces = interfaces;

View file

@ -264,9 +264,6 @@ namespace PepperDash.Essentials.AppServer.Messengers
message.Name = _device.Name; message.Name = _device.Name;
message.MessageBasePath = MessagePath;
var token = JToken.FromObject(message); var token = JToken.FromObject(message);
PostStatusMessage(token, MessagePath, clientId); PostStatusMessage(token, MessagePath, clientId);

View file

@ -33,7 +33,7 @@
<Compile Remove="Messengers\SIMPLVtcMessenger.cs" /> <Compile Remove="Messengers\SIMPLVtcMessenger.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PepperDash.Core\PepperDash.Core.csproj" /> <ProjectReference Include="..\PepperDash.Core\PepperDash.Core.csproj" />

View file

@ -1,61 +0,0 @@
using System;
using Newtonsoft.Json;
namespace PepperDash.Essentials
{
/// <summary>
/// Represents the version information reported by a connected Mobile Control UI client
/// </summary>
public class ConnectedClientVersionInfo
{
/// <summary>
/// Gets or sets the client id
/// </summary>
[JsonProperty("clientId")]
public string ClientId { get; set; }
/// <summary>
/// Gets or sets the room key the client joined
/// </summary>
[JsonProperty("roomKey")]
public string RoomKey { get; set; }
/// <summary>
/// Gets or sets the touchpanel key the client joined as, if any
/// </summary>
[JsonProperty("touchpanelKey")]
public string TouchpanelKey { get; set; }
/// <summary>
/// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION)
/// </summary>
[JsonProperty("appVersion")]
public string AppVersion { get; set; }
/// <summary>
/// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured
/// </summary>
[JsonProperty("expectedAppVersion")]
public string ExpectedAppVersion { get; set; }
/// <summary>
/// Gets or sets the UTC time the client last reported this version
/// </summary>
[JsonProperty("lastSeen")]
public DateTime LastSeen { get; set; }
/// <summary>
/// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate
/// without affecting the internally tracked instance
/// </summary>
public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo
{
ClientId = ClientId,
RoomKey = RoomKey,
TouchpanelKey = TouchpanelKey,
AppVersion = AppVersion,
ExpectedAppVersion = ExpectedAppVersion,
LastSeen = LastSeen
};
}
}

View file

@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
@ -41,10 +40,9 @@ namespace PepperDash.Essentials
public bool EnableApiServer { get; set; } = true; public bool EnableApiServer { get; set; } = true;
/// <summary> /// <summary>
/// Enables subscriptions for messengers /// Enable subscriptions for Messengers
/// </summary> /// </summary>
[JsonProperty("enableMessengerSubscriptions")] [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; } public bool EnableMessengerSubscriptions { get; set; }
} }
@ -290,4 +288,4 @@ namespace PepperDash.Essentials
/// </summary> /// </summary>
NEO NEO
} }
} }

View file

@ -69,32 +69,11 @@ namespace PepperDash.Essentials
private readonly Dictionary<string, IMobileControlMessenger> _defaultMessengers = private readonly Dictionary<string, IMobileControlMessenger> _defaultMessengers =
new Dictionary<string, IMobileControlMessenger>(); new Dictionary<string, IMobileControlMessenger>();
private readonly Dictionary<string, ConnectedClientVersionInfo> _connectedClientVersions =
new Dictionary<string, ConnectedClientVersionInfo>(StringComparer.InvariantCultureIgnoreCase);
private readonly object _connectedClientVersionsLock = new object();
/// <summary> /// <summary>
/// Get the custom messengers with subscriptions /// Get the custom messengers with subscriptions
/// </summary> /// </summary>
public ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions> Messengers => new ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions>(_messengers.Values.OfType<IMobileControlMessengerWithSubscriptions>().ToDictionary(k => k.Key, v => v)); public ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions> Messengers => new ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions>(_messengers.Values.OfType<IMobileControlMessengerWithSubscriptions>().ToDictionary(k => k.Key, v => v));
/// <summary>
/// Gets the most recently reported UI app version for each connected client, keyed by clientId
/// </summary>
public ReadOnlyDictionary<string, ConnectedClientVersionInfo> ConnectedClientVersions
{
get
{
lock (_connectedClientVersionsLock)
{
return new ReadOnlyDictionary<string, ConnectedClientVersionInfo>(
_connectedClientVersions.ToDictionary(kv => kv.Key, kv => kv.Value.Clone())
);
}
}
}
/// <summary> /// <summary>
/// Get the default messengers /// Get the default messengers
/// </summary> /// </summary>
@ -158,7 +137,7 @@ namespace PepperDash.Essentials
"No system_url value defined in config. Checking for value from SIMPL Bridge." "No system_url value defined in config. Checking for value from SIMPL Bridge."
); );
if (string.IsNullOrEmpty(SystemUrl)) if (!string.IsNullOrEmpty(SystemUrl))
{ {
this.LogError( this.LogError(
"No system_url value defined in config or SIMPL Bridge. Unable to connect to Mobile Control." "No system_url value defined in config or SIMPL Bridge. Unable to connect to Mobile Control."
@ -1803,28 +1782,6 @@ namespace PepperDash.Essentials
" Not Enabled in Config.\r\n" " 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"
);
}
}
} }
/// <summary> /// <summary>
@ -2224,8 +2181,6 @@ namespace PepperDash.Essentials
var roomKey = content["roomKey"].Value<string>(); var roomKey = content["roomKey"].Value<string>();
var touchpanelKey = content.SelectToken("touchpanelKey"); var touchpanelKey = content.SelectToken("touchpanelKey");
TrackClientAppVersion(clientId, roomKey, touchpanelKey?.Value<string>(), content.SelectToken("appVersion")?.Value<string>());
if (_roomCombiner == null) if (_roomCombiner == null)
{ {
var message = new MobileControlMessage var message = new MobileControlMessage
@ -2297,50 +2252,6 @@ namespace PepperDash.Essentials
SendTouchpanelKey(clientId, touchpanelKey); SendTouchpanelKey(clientId, touchpanelKey);
} }
/// <summary>
/// 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.
/// </summary>
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) private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken)
{ {
if (touchpanelKeyToken == null) if (touchpanelKeyToken == null)

View file

@ -38,7 +38,7 @@
<Compile Remove="RoomBridges\SourceDeviceMapDictionary.cs" /> <Compile Remove="RoomBridges\SourceDeviceMapDictionary.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.ProgramLibrary" Version="2.21.90" />
<PackageReference Include="WebSocketSharp-netstandard" Version="1.0.1" /> <PackageReference Include="WebSocketSharp-netstandard" Version="1.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -25,28 +25,11 @@ namespace PepperDash.Essentials.Touchpanel
/// Mobile Control touchpanel controller that provides app control, Zoom integration, /// Mobile Control touchpanel controller that provides app control, Zoom integration,
/// and mobile control functionality for Crestron touchpanels. /// and mobile control functionality for Crestron touchpanels.
/// </summary> /// </summary>
public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme, ICommunicationMonitor public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme
{ {
private readonly MobileControlTouchpanelProperties localConfig; private readonly MobileControlTouchpanelProperties localConfig;
private IMobileControlRoomMessenger _bridge; private IMobileControlRoomMessenger _bridge;
/// <summary>
/// Gets the CommunicationMonitor tracking the panel's online/offline state
/// </summary>
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; private string _appUrl;
/// <summary> /// <summary>
@ -145,11 +128,6 @@ namespace PepperDash.Essentials.Touchpanel
{ {
localConfig = config; localConfig = config;
if (panel != null)
{
CommunicationMonitor = new CrestronGenericBaseCommunicationMonitor(this, panel, 120000, 300000);
}
AddPostActivationAction(SubscribeForMobileControlUpdates); AddPostActivationAction(SubscribeForMobileControlUpdates);
ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme); ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme);
@ -388,8 +366,6 @@ namespace PepperDash.Essentials.Touchpanel
/// </summary> /// </summary>
public override bool CustomActivate() public override bool CustomActivate()
{ {
CommunicationMonitor?.Start();
var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this); var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this);
var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this); var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this);
@ -417,17 +393,6 @@ namespace PepperDash.Essentials.Touchpanel
return base.CustomActivate(); return base.CustomActivate();
} }
/// <summary>
/// Stops the CommunicationMonitor on deactivation.
/// </summary>
/// <returns>True if deactivation was successful; otherwise, false.</returns>
public override bool Deactivate()
{
CommunicationMonitor?.Stop();
return base.Deactivate();
}
/// <summary> /// <summary>
/// Handles device extender signal changes for system reserved signals. /// Handles device extender signal changes for system reserved signals.
/// </summary> /// </summary>
@ -554,8 +519,15 @@ namespace PepperDash.Essentials.Touchpanel
return false; return false;
}) ? csIpAddress.ToString() : processorIp; }) ? csIpAddress.ToString() : processorIp;
// replace the host but preserve whatever scheme (http/https) is already present in the URL var match = Regex.Match(url, @"^http://([^:/]+):\d+/mc/app\?token=.+$");
var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}"); 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}");
this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl); this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl);
@ -768,7 +740,7 @@ namespace PepperDash.Essentials.Touchpanel
/// </summary> /// </summary>
public MobileControlTouchpanelControllerFactory() public MobileControlTouchpanelControllerFactory()
{ {
TypeNames = new List<string>() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mctsw1080", "mcts1080", "mcxpanel", "mcdge1000" }; TypeNames = new List<string>() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mcxpanel", "mcdge1000" };
MinimumEssentialsFrameworkVersion = "2.0.0"; MinimumEssentialsFrameworkVersion = "2.0.0";
factories = new Dictionary<string, Func<uint, CrestronControlSystem, string, BasicTriListWithSmartObject>> factories = new Dictionary<string, Func<uint, CrestronControlSystem, string, BasicTriListWithSmartObject>>
@ -793,8 +765,6 @@ namespace PepperDash.Essentials.Touchpanel
{"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)}, {"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)},
{"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)}, {"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)},
{"ts1070", (id, controlSystem, projectName) => new Ts1070(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)} {"dge1000", (id, controlSystem, projectName) => new Dge1000(id, controlSystem)}
}; };
} }

View file

@ -1,41 +0,0 @@
using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json;
using PepperDash.Core.Web.RequestHandlers;
using PepperDash.Essentials.WebSocketServer;
namespace PepperDash.Essentials.WebApiHandlers
{
/// <summary>
/// Represents a DeleteAllUiClientsHandler
/// </summary>
public class DeleteAllUiClientsHandler : WebApiBaseRequestHandler
{
private readonly MobileControlWebsocketServer server;
/// <summary>
/// Essentials CWS API handler for the MC Direct Server
/// </summary>
/// <param name="directServer">Direct Server instance</param>
public DeleteAllUiClientsHandler(MobileControlWebsocketServer directServer) : base(true)
{
server = directServer;
}
/// <summary>
/// Deletes all clients from the Direct Server
/// </summary>
/// <param name="context">HTTP Context for this request</param>
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();
}
}
}

View file

@ -127,16 +127,6 @@ namespace PepperDash.Essentials.WebSocketServer
/// </summary> /// </summary>
public int Port { get; private set; } public int Port { get; private set; }
/// <summary>
/// Gets the HTTP scheme to use for generated URLs, based on whether the direct server is configured as secure
/// </summary>
private string HttpScheme => _parent.Config.DirectServer.Secure ? "https" : "http";
/// <summary>
/// Gets the WebSocket scheme to use for generated URLs, based on whether the direct server is configured as secure
/// </summary>
private string WsScheme => _parent.Config.DirectServer.Secure ? "wss" : "ws";
/// <summary> /// <summary>
/// Gets the user app URL prefix /// Gets the user app URL prefix
/// </summary> /// </summary>
@ -144,8 +134,7 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
get get
{ {
return string.Format("{0}://{1}:{2}{3}?token=", return string.Format("http://{0}:{1}{2}?token=",
HttpScheme,
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port, Port,
_userAppBaseHref); _userAppBaseHref);
@ -249,17 +238,11 @@ namespace PepperDash.Essentials.WebSocketServer
var routes = new List<HttpCwsRoute> var routes = new List<HttpCwsRoute>
{ {
new HttpCwsRoute($"device/{Key}/client") new HttpCwsRoute($"devices/{Key}/client")
{ {
Name = "ClientHandler", Name = "ClientHandler",
RouteHandler = new UiClientHandler(this) RouteHandler = new UiClientHandler(this)
}, },
new HttpCwsRoute($"device/{Key}/deleteAllUiClients")
{
Name = "DeleteAllClientsHandler",
RouteHandler = new DeleteAllUiClientsHandler(this)
},
}; };
apiServer.AddRoute(routes); apiServer.AddRoute(routes);
@ -284,7 +267,7 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
base.Initialize(); base.Initialize();
_server = new HttpServer(Port, _parent.Config.DirectServer.Secure); _server = new HttpServer(Port, false);
_server.OnGet += Server_OnGet; _server.OnGet += Server_OnGet;
@ -302,7 +285,7 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
ClientCertificateRequired = false, ClientCertificateRequired = false,
CheckCertificateRevocation = false, CheckCertificateRevocation = false,
EnabledSslProtocols = SslProtocols.Tls12 EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11
}; };
} }
@ -414,11 +397,11 @@ namespace PepperDash.Essentials.WebSocketServer
ip = csIpAddress.ToString(); ip = csIpAddress.ToString();
} }
var appUrl = $"{HttpScheme}://{ip}:{Port}/mc/app?token={touchpanel.Key}"; var appUrl = $"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}";
this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key); this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key);
touchpanel.Touchpanel.SetAppUrl(appUrl); touchpanel.Touchpanel.SetAppUrl($"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}");
} }
} }
@ -498,7 +481,7 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
var config = new MobileControlApplicationConfig var config = new MobileControlApplicationConfig
{ {
ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, Port), ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port),
GatewayAppPath = "", GatewayAppPath = "",
LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png", LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png",
EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false, EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false,
@ -925,7 +908,7 @@ namespace PepperDash.Essentials.WebSocketServer
/// <summary> /// <summary>
/// Removes all clients from the server /// Removes all clients from the server
/// </summary> /// </summary>
public void RemoveAllTokens(string s) private void RemoveAllTokens(string s)
{ {
if (s == "?" || string.IsNullOrEmpty(s)) if (s == "?" || string.IsNullOrEmpty(s))
{ {
@ -1109,7 +1092,6 @@ namespace PepperDash.Essentials.WebSocketServer
res.StatusCode = 200; res.StatusCode = 200;
res.Close(); 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") 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"), Content = new StringContent(body, Encoding.UTF8, "application/json"),
@ -1162,11 +1144,6 @@ namespace PepperDash.Essentials.WebSocketServer
var qp = req.QueryString; var qp = req.QueryString;
var token = qp["token"]; 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); this.LogVerbose("Join Room Request with token: {token}", token);
byte[] body; byte[] body;
@ -1230,7 +1207,8 @@ namespace PepperDash.Essentials.WebSocketServer
this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now); this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now);
// Construct WebSocket URL with clientId query parameter // Construct WebSocket URL with clientId query parameter
var wsUrl = $"{WsScheme}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}"; var wsProtocol = "ws";
var wsUrl = $"{wsProtocol}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}";
// Construct the response object // Construct the response object
JoinResponse jRes = new JoinResponse JoinResponse jRes = new JoinResponse
@ -1242,8 +1220,7 @@ namespace PepperDash.Essentials.WebSocketServer
Config = _parent.GetConfigWithPluginVersion(), Config = _parent.GetConfigWithPluginVersion(),
CodeExpires = new DateTime().AddYears(1), CodeExpires = new DateTime().AddYears(1),
UserCode = bridge.UserCode, UserCode = bridge.UserCode,
UserAppUrl = string.Format("{0}://{1}:{2}/mc/app", UserAppUrl = string.Format("http://{0}:{1}/mc/app",
HttpScheme,
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port), Port),
WebSocketUrl = wsUrl, WebSocketUrl = wsUrl,
@ -1268,8 +1245,6 @@ namespace PepperDash.Essentials.WebSocketServer
{ {
res.StatusCode = 200; res.StatusCode = 200;
res.ContentType = "application/json"; 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 version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion };
var message = JsonConvert.SerializeObject(version); var message = JsonConvert.SerializeObject(version);
this.LogVerbose("{message}", message); this.LogVerbose("{message}", message);

View file

@ -1,257 +0,0 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using PepperDash.Core;
using Serilog.Events;
namespace PepperDash.Essentials
{
/// <summary>
/// Handles extracting embedded asset bundles and moving configuration files from the
/// application directory to the program file-path prefix at startup.
/// Implemented using <c>System.IO</c> types so it can run (and be tested) outside
/// of a Crestron runtime environment.
/// </summary>
internal static class AssetLoader
{
/// <summary>
/// Scans <paramref name="applicationDirectoryPath"/> for well-known zip bundles and
/// JSON configuration files and deploys them to <paramref name="filePathPrefix"/>.
/// </summary>
/// <param name="applicationDirectoryPath">
/// The directory to scan (typically the Crestron application root).
/// </param>
/// <param name="filePathPrefix">
/// The program's runtime working directory (e.g. <c>/nvram/program1/</c>).
/// </param>
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);
}
}
}
}

View file

@ -1,4 +1,6 @@
using System; using System;
using System.Collections.Generic;
using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using Crestron.SimplSharp; using Crestron.SimplSharp;
@ -27,8 +29,6 @@ namespace PepperDash.Essentials
private CEvent _initializeEvent; private CEvent _initializeEvent;
private const long StartupTime = 500; private const long StartupTime = 500;
// private const string minimumFirmwareVersion = "2.8006.00110";
/// <summary> /// <summary>
/// Initializes a new instance of the ControlSystem class /// Initializes a new instance of the ControlSystem class
/// </summary> /// </summary>
@ -48,24 +48,6 @@ namespace PepperDash.Essentials
/// <inheritdoc /> /// <inheritdoc />
public override void InitializeSystem() 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 // 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. // to allow any HD-BaseT DM endpoints to register first.
bool preventInitializationComplete = Global.ControlSystemIsDmpsType; bool preventInitializationComplete = Global.ControlSystemIsDmpsType;
@ -130,8 +112,14 @@ namespace PepperDash.Essentials
(ConfigReader.ConfigObject, Newtonsoft.Json.Formatting.Indented).Replace(Environment.NewLine, "\r\n")); (ConfigReader.ConfigObject, Newtonsoft.Json.Formatting.Indented).Replace(Environment.NewLine, "\r\n"));
}, "showconfig", "Shows the current running merged config", ConsoleAccessLevelEnum.AccessOperator); }, "showconfig", "Shows the current running merged config", ConsoleAccessLevelEnum.AccessOperator);
CrestronConsole.AddNewConsoleCommand( CrestronConsole.AddNewConsoleCommand(s =>
PrintPortalInfo, 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),
"portalinfo", "portalinfo",
"Shows portal URLS from configuration", "Shows portal URLS from configuration",
ConsoleAccessLevelEnum.AccessOperator); ConsoleAccessLevelEnum.AccessOperator);
@ -154,29 +142,6 @@ 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);
}
/// <summary> /// <summary>
/// DeterminePlatform method /// DeterminePlatform method
/// </summary> /// </summary>
@ -274,8 +239,13 @@ namespace PepperDash.Essentials
PluginLoader.AddProgramAssemblies(); PluginLoader.AddProgramAssemblies();
_ = new Core.DeviceFactory(); _ = new Core.DeviceFactory();
// _ = new Devices.Common.DeviceFactory();
// _ = new DeviceFactory();
LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix); // _ = new ProcessorExtensionDeviceFactory();
// _ = new MobileControlFactory();
LoadAssets();
Debug.LogMessage(LogEventLevel.Information, "Starting Essentials load from configuration"); Debug.LogMessage(LogEventLevel.Information, "Starting Essentials load from configuration");
@ -286,9 +256,10 @@ namespace PepperDash.Essentials
PluginLoader.LoadPlugins(); PluginLoader.LoadPlugins();
Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config..."); Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config...");
if (!ConfigReader.LoadConfig2() || ConfigReader.ConfigObject == null) if (!ConfigReader.LoadConfig2())
{ {
Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file."); Debug.LogMessage(LogEventLevel.Information, "Essentials Load complete with errors");
return;
} }
Load(); Load();
@ -410,12 +381,6 @@ namespace PepperDash.Essentials
new Core.Monitoring.SystemMonitorController("systemMonitor")); 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) foreach (var devConf in ConfigReader.ConfigObject.Devices)
{ {
IKeyed newDev = null; IKeyed newDev = null;
@ -469,7 +434,7 @@ namespace PepperDash.Essentials
var tlc = TieLineCollection.Default; var tlc = TieLineCollection.Default;
if (ConfigReader.ConfigObject?.TieLines == null) if (ConfigReader.ConfigObject.TieLines == null)
{ {
return; return;
} }
@ -497,7 +462,7 @@ namespace PepperDash.Essentials
{ {
try try
{ {
if (!string.IsNullOrEmpty(args) && args.Contains("?")) if (args.Contains("?"))
{ {
CrestronConsole.ConsoleCommandResponse("Usage: listtielines [signaltype]\r\n"); CrestronConsole.ConsoleCommandResponse("Usage: listtielines [signaltype]\r\n");
CrestronConsole.ConsoleCommandResponse("Signal types: Audio, Video, SecondaryAudio, AudioVideo, UsbInput, UsbOutput\r\n"); CrestronConsole.ConsoleCommandResponse("Signal types: Audio, Video, SecondaryAudio, AudioVideo, UsbInput, UsbOutput\r\n");
@ -543,7 +508,7 @@ namespace PepperDash.Essentials
{ {
try try
{ {
if (!string.IsNullOrEmpty(args) && args.Contains("?")) if (args.Contains("?"))
{ {
CrestronConsole.ConsoleCommandResponse("Usage: visualizeroutes [signaltype] [-s source] [-d destination]\r\n"); CrestronConsole.ConsoleCommandResponse("Usage: visualizeroutes [signaltype] [-s source] [-d destination]\r\n");
CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n"); CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n");
@ -592,7 +557,7 @@ namespace PepperDash.Essentials
{ {
try try
{ {
if (!string.IsNullOrEmpty(args) && args.Contains("?")) if (args.Contains("?"))
{ {
CrestronConsole.ConsoleCommandResponse("Usage: visualizecurrentroutes [signaltype] [-s source] [-d destination]\r\n"); CrestronConsole.ConsoleCommandResponse("Usage: visualizecurrentroutes [signaltype] [-s source] [-d destination]\r\n");
CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n"); CrestronConsole.ConsoleCommandResponse(" signaltype: Audio, Video, AudioVideo, etc.\r\n");
@ -766,7 +731,7 @@ namespace PepperDash.Essentials
/// </summary> /// </summary>
public void LoadRooms() 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."); Debug.LogMessage(LogEventLevel.Information, "Notice: Configuration contains no rooms - Is this intentional? This may be a valid configuration.");
return; return;
@ -801,16 +766,15 @@ namespace PepperDash.Essentials
/// <summary> /// <summary>
/// Fires up a logo server if not already running /// Fires up a logo server if not already running
/// </summary> /// </summary>
[Obsolete("Logo server is no longer supported and will be removed in a future release.")]
void LoadLogoServer() void LoadLogoServer()
{ {
if (ConfigReader.ConfigObject?.Rooms == null) if (ConfigReader.ConfigObject.Rooms == null)
{ {
Debug.LogMessage(LogEventLevel.Information, "No rooms configured. Bypassing Logo server startup."); Debug.LogMessage(LogEventLevel.Information, "No rooms configured. Bypassing Logo server startup.");
return; return;
} }
if (ConfigReader.ConfigObject?.Rooms == null || if (
!ConfigReader.ConfigObject.Rooms.Any( !ConfigReader.ConfigObject.Rooms.Any(
CheckRoomConfig)) CheckRoomConfig))
{ {
@ -861,8 +825,142 @@ namespace PepperDash.Essentials
} }
} }
internal static void LoadAssets(string applicationDirectoryPath, string filePathPrefix) => private static void LoadAssets()
AssetLoader.Load(applicationDirectoryPath, filePathPrefix); {
var applicationDirectory = new DirectoryInfo(Global.ApplicationDirectoryPathPrefix);
Debug.LogMessage(LogEventLevel.Information, "Searching: {applicationDirectory:l} for embedded assets - {Destination}", applicationDirectory.FullName, Global.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);
}
}
} }
} }

View file

@ -48,7 +48,7 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.Program" Version="2.21.274" /> <PackageReference Include="Crestron.SimplSharp.SDK.Program" Version="2.21.90" />
<PackageReference Include="System.IO.Compression" Version="4.0.0" /> <PackageReference Include="System.IO.Compression" Version="4.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>