From 8817d70f0793ee804747940da0f195e7cddff580 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 20 Mar 2026 12:03:00 -0600 Subject: [PATCH 01/84] feat: add interfaces for VLAN and PoE management in network switches --- .../INetworkSwitchControl.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs new file mode 100644 index 00000000..4ad6094d --- /dev/null +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -0,0 +1,48 @@ +namespace PepperDash.Essentials.Core.DeviceTypeInterfaces +{ + /// + /// Interface for network switches that support VLAN assignment on individual ports. + /// + public interface INetworkSwitchVlanManager + { + /// + /// Returns the current access VLAN ID configured on the port. + /// Return -1 when the value is unavailable (e.g. the switch has not been polled yet + /// or the implementation does not support VLAN queries). + /// + /// Switch port identifier + /// VLAN ID or -1 when unavailable + int GetPortCurrentVlan(string port); + + /// + /// Changes the access VLAN of a single switch port. + /// The implementation is responsible for entering/exiting privileged/config mode. + /// + /// Switch port identifier (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco) + /// Target VLAN ID (1-4093) + void SetPortVlan(string port, uint vlanId); + } + + /// + /// Interface for network switches that support Power over Ethernet (PoE) control on individual ports. + /// + public interface INetworkSwitchPoeManager + { + /// + /// Enables or disables PoE power delivery on a single switch port. + /// The implementation is responsible for entering/exiting privileged/config mode. + /// + /// Switch port identifier + /// True to enable PoE; false to disable PoE + void SetPortPoeState(string port, bool enabled); + } + + /// + /// Standardized interface for network switch devices that support per-port PoE control + /// and VLAN assignment. + /// + public interface INetworkSwitchPoeVlanManager : INetworkSwitchVlanManager, INetworkSwitchPoeManager + { + + } +} From 76311b83ff866fce29ad6768855ab8eb8dbabeba Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 20 Mar 2026 13:25:58 -0600 Subject: [PATCH 02/84] feat: add event and arguments for port state changes in network switch management --- .../INetworkSwitchControl.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 4ad6094d..6174ff28 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -1,3 +1,5 @@ +using System; + namespace PepperDash.Essentials.Core.DeviceTypeInterfaces { /// @@ -43,6 +45,58 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces /// public interface INetworkSwitchPoeVlanManager : INetworkSwitchVlanManager, INetworkSwitchPoeManager { + /// + /// Event that is raised when the state of a switch port changes, such as a VLAN change or PoE state change. + /// + event EventHandler PortStateChanged; } + + /// + /// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes. + /// + public class NetworkSwitchPortEventArgs : EventArgs + { + /// + /// The identifier of the port that changed state (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco). + /// + public string Port { get; private set; } + + /// + /// The type of event that occurred on the port (e.g. VLAN change, PoE enabled/disabled). + /// + public NetworkSwitchPortEventType EventType { get; private set; } + + /// + /// Constructor for NetworkSwitchPortEventArgs + /// + /// The identifier of the port that changed state + /// The type of event that occurred on the port + public NetworkSwitchPortEventArgs(string port, NetworkSwitchPortEventType eventType) + { + Port = port; + EventType = eventType; + } + } + + /// + /// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes. + /// + public enum NetworkSwitchPortEventType + { + /// + /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan + /// + VlanChanged, + + /// + /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState + /// + PoEDisabled, + + /// + /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState + /// + PoEEnabled + } } From 6db7581295d746040b0a84ffa058f1f9d53f0ca9 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 20 Mar 2026 14:48:58 -0600 Subject: [PATCH 03/84] feat: add Unknown event type to NetworkSwitchPortEventType enum for improved event handling --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 6174ff28..0aaf89d2 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -84,6 +84,11 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces /// public enum NetworkSwitchPortEventType { + /// + /// Indicates that the type of event is unknown or cannot be determined. + /// + Unknown, + /// /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan /// From 84c730b7a1255c8c167d784f186ec68be4997cfe Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 20 Mar 2026 14:53:21 -0600 Subject: [PATCH 04/84] feat: enhance NetworkSwitchPortEventType enum with additional states for VLAN and PoE changes --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 0aaf89d2..aca38333 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -89,16 +89,31 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces /// Unknown, + /// + /// Indicates that a VLAN change is in progress on the port, either through a call to SetPortVlan or an external change detected by polling. + /// + VlanChangeInProgress, + /// /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan /// VlanChanged, + /// + /// Indicates that PoE is being disabled on the port, either through a call to SetPortPoeState or an external change detected by polling. + /// + PoeDisableInProgress, + /// /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState /// PoEDisabled, + /// + /// Indicates that PoE is being enabled on the port, either through a call to SetPortPoeState or an external change detected by polling. + /// + PoeEnableInProgress, + /// /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState /// From 1b10910cc28324b98feef7ed6f15400b1fdc6632 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Wed, 1 Apr 2026 08:56:10 -0600 Subject: [PATCH 05/84] fix: update GetFeedbacksForDeviceRequestHandler to return JSON response for missing device --- .../GetFeedbacksForDeviceRequestHandler.cs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs index ca9eeb81..7d947e19 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs @@ -1,13 +1,14 @@ -using System.Linq; +using System; +using System.Linq; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core.Web.RequestHandlers; namespace PepperDash.Essentials.Core.Web.RequestHandlers { - /// - /// Represents a GetFeedbacksForDeviceRequestHandler - /// + /// + /// Represents a GetFeedbacksForDeviceRequestHandler + /// public class GetFeedbacksForDeviceRequestHandler : WebApiBaseRequestHandler { /// @@ -51,8 +52,20 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var device = DeviceManager.GetDeviceForKey(deviceObj.ToString()) as IHasFeedback; if (device == null) { - context.Response.StatusCode = 404; - context.Response.StatusDescription = "Not Found"; + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + var resp = new + { + BoolValues = Array.Empty(), + IntValues = Array.Empty(), + SerialValues = Array.Empty() + }; + var respJs = JsonConvert.SerializeObject(resp, Formatting.Indented); + + context.Response.Write(respJs, false); + context.Response.End(); return; @@ -76,7 +89,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers Value = feedback.IntValue }; - var stringFeedback = + var stringFeedback = from feedback in device.Feedbacks.OfType() where !string.IsNullOrEmpty(feedback.Key) select new From 507130c1ae08246e3bcb560601354ba35bcc130e Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 13:07:52 -0600 Subject: [PATCH 06/84] feat: refactor certificate generation to use BouncyCastle for improved security and flexibility Co-authored-by: Copilot --- .../Logging/DebugWebsocketSink.cs | 106 ++++++++++-------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index fc3b0247..e083da2d 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -8,10 +8,17 @@ using Crestron.SimplSharp; using WebSocketSharp; using System.Security.Authentication; using WebSocketSharp.Net; -using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.IO; +using Org.BouncyCastle.Asn1; using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; using Serilog.Formatting; using Serilog.Formatting.Json; @@ -114,51 +121,62 @@ namespace PepperDash.Core var subjectName = string.Format("CN={0}.{1}", hostName, domainName); var fqdn = string.Format("{0}.{1}", hostName, domainName); - using (var rsa = RSA.Create(2048)) + var random = new SecureRandom(); + + // Generate RSA 2048 key pair + var keyPairGenerator = new RsaKeyPairGenerator(); + keyPairGenerator.Init(new KeyGenerationParameters(random, 2048)); + var keyPair = keyPairGenerator.GenerateKeyPair(); + + // Build certificate + var certGenerator = new X509V3CertificateGenerator(); + certGenerator.SetSerialNumber(BigInteger.ValueOf(Math.Abs(DateTime.UtcNow.Ticks))); + certGenerator.SetIssuerDN(new X509Name(subjectName)); + certGenerator.SetSubjectDN(new X509Name(subjectName)); + certGenerator.SetNotBefore(DateTime.UtcNow); + certGenerator.SetNotAfter(DateTime.UtcNow.AddYears(2)); + certGenerator.SetPublicKey(keyPair.Public); + + // Extended Key Usage: server + client auth + certGenerator.AddExtension(X509Extensions.ExtendedKeyUsage, false, + new ExtendedKeyUsage(new[] { KeyPurposeID.id_kp_serverAuth, KeyPurposeID.id_kp_clientAuth })); + + // Subject Alternative Names: DNS + IP + System.Net.IPAddress parsedIp; + if (System.Net.IPAddress.TryParse(ipAddress, out parsedIp)) { - - var request = new CertificateRequest( - subjectName, - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - // Subject Key Identifier - request.CertificateExtensions.Add( - new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); - - // Extended Key Usage: server + client auth - request.CertificateExtensions.Add( - new X509EnhancedKeyUsageExtension( - new OidCollection - { - new Oid("1.3.6.1.5.5.7.3.1"), // id-kp-serverAuth - new Oid("1.3.6.1.5.5.7.3.2") // id-kp-clientAuth - }, - false)); - - // Subject Alternative Names: DNS + IP - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName(fqdn); - if (System.Net.IPAddress.TryParse(ipAddress, out var ip)) - sanBuilder.AddIpAddress(ip); - request.CertificateExtensions.Add(sanBuilder.Build()); - - var notBefore = DateTimeOffset.UtcNow; - var notAfter = notBefore.AddYears(2); - - using (var cert = request.CreateSelfSigned(notBefore, notAfter)) - { - - var separator = Path.DirectorySeparatorChar; - var outputPath = string.Format("{0}user{1}{2}.pfx", separator, separator, _certificateName); - - var pfxBytes = cert.Export(X509ContentType.Pfx, _certificatePassword); - File.WriteAllBytes(outputPath, pfxBytes); - - CrestronConsole.PrintLine(string.Format("CreateCert: Certificate written to {0}", outputPath)); - } + 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()) + { + pkcs12Store.Save(ms, _certificatePassword.ToCharArray(), random); + File.WriteAllBytes(outputPath, ms.ToArray()); + } + + CrestronConsole.PrintLine(string.Format("CreateCert: Certificate written to {0}", outputPath)); } catch (Exception ex) { From 1b32761e8e438a766562a6b37eff72950ebfda7e Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 13:45:17 -0600 Subject: [PATCH 07/84] fix: invert condition for system_url validation in MobileControlSystemController Co-authored-by: Copilot --- .../MobileControlSystemController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index 3adcaf87..3d466f5b 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -137,7 +137,7 @@ namespace PepperDash.Essentials "No system_url value defined in config. Checking for value from SIMPL Bridge." ); - if (!string.IsNullOrEmpty(SystemUrl)) + if (string.IsNullOrEmpty(SystemUrl)) { this.LogError( "No system_url value defined in config or SIMPL Bridge. Unable to connect to Mobile Control." From 89c23f5432005b38c9796f79a65c16a80932d5e1 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 09:01:27 -0600 Subject: [PATCH 08/84] feat: enhance certificate handling with BouncyCastle and remove firmware version checks Co-authored-by: Copilot --- .../Logging/DebugWebsocketSink.cs | 70 +++++++++++++++++-- src/PepperDash.Essentials/ControlSystem.cs | 28 ++++---- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index e083da2d..1f4dda7a 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -15,10 +15,12 @@ using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Math; using Org.BouncyCastle.Pkcs; using Org.BouncyCastle.Security; using Org.BouncyCastle.X509; +using System.Security.Cryptography; using Serilog.Formatting; using Serilog.Formatting.Json; @@ -172,7 +174,15 @@ namespace PepperDash.Core using (var ms = new MemoryStream()) { - pkcs12Store.Save(ms, _certificatePassword.ToCharArray(), random); + var passwordChars = _certificatePassword.ToCharArray(); + try + { + pkcs12Store.Save(ms, passwordChars, random); + } + finally + { + Array.Clear(passwordChars, 0, passwordChars.Length); + } File.WriteAllBytes(outputPath, ms.ToArray()); } @@ -215,23 +225,69 @@ namespace PepperDash.Core private static X509Certificate2 LoadOrRecreateCert(string certPath, string certPassword) { + if (!File.Exists(certPath)) + CreateCert(); + try { - // EphemeralKeySet is required on Linux/OpenSSL (Crestron 4-series) to avoid - // key-container persistence failures, and avoids the private key export restriction. - return new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet); + return LoadCertFromBouncyCastle(certPath, certPassword); } catch (Exception ex) { - // Cert is stale or was generated by an incompatible library (e.g. old BouncyCastle output). - // Delete it, regenerate with the BCL path, and retry once. + // 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 new X509Certificate2(certPath, certPassword, X509KeyStorageFlags.EphemeralKeySet); + return LoadCertFromBouncyCastle(certPath, certPassword); } } + /// + /// Loads a PKCS#12 file written by BouncyCastle and returns an with + /// private key attached via . + /// Using BouncyCastle's own reader avoids the .NET/Mono PFX parser, which can reject + /// BouncyCastle-generated archives on the Crestron runtime. + /// + 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); + + foreach (string alias in store.Aliases) + { + if (!store.IsKeyEntry(alias)) continue; + + var keyEntry = store.GetKey(alias); + var certChain = store.GetCertificateChain(alias); + if (certChain == null || certChain.Length == 0) continue; + + // Build X509Certificate2 from raw DER — no PFX parsing by .NET needed. + var cert = new X509Certificate2(certChain[0].Certificate.GetEncoded()); + + // Attach the private key via RSACryptoServiceProvider (available on all target runtimes). + var rsaParams = DotNetUtilities.ToRSAParameters( + (RsaPrivateCrtKeyParameters)keyEntry.Key); + var rsa = new RSACryptoServiceProvider(); + rsa.ImportParameters(rsaParams); + cert.PrivateKey = rsa; + + return cert; + } + } + } + finally + { + Array.Clear(passwordChars, 0, passwordChars.Length); + } + + throw new InvalidOperationException("No key entry found in PKCS#12 store: " + certPath); + } + private void Start(int port, string certPath = "", string certPassword = "") { try diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index e6c61c60..d4a7545a 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -27,7 +27,7 @@ namespace PepperDash.Essentials private CEvent _initializeEvent; private const long StartupTime = 500; - private const string minimumFirmwareVersion = "2.8006.00110"; + // private const string minimumFirmwareVersion = "2.8006.00110"; /// /// Initializes a new instance of the ControlSystem class @@ -50,21 +50,21 @@ namespace PepperDash.Essentials { // 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; + // var fwVersion = InitialParametersClass.FirmwareVersion; - Debug.LogInformation("Control System Hardware Version: {fwVersion}", fwVersion); + // 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; - } + // // split the version into parts and compare against minimumFirmwareVersion + // var versionParts = fwVersion.Split('.').Select(int.Parse).ToArray(); + // var minParts = minimumFirmwareVersion.Split('.').Select(int.Parse).ToArray(); + // if (versionParts.Length < minParts.Length + // || versionParts[0] < minParts[0] + // || (versionParts[0] == minParts[0] && versionParts[1] < minParts[1]) + // || (versionParts[0] == minParts[0] && versionParts[1] == minParts[1] && versionParts[2] <= minParts[2])) + // { + // Debug.LogFatal("Firmware version {fwVersion} is too low to run this version of Essentials. Please upgrade to greater than v{minimumFirmwareVersion}.", fwVersion, minimumFirmwareVersion); + // return; + // } // If the control system is a DMPS type, we need to wait to exit this method until all devices have had time to activate // to allow any HD-BaseT DM endpoints to register first. From c52d585a0cc5b64484820589442ea9e9423272d3 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 10:44:54 -0600 Subject: [PATCH 09/84] feat: Allow WebAPI to load if no config file is present --- .../Web/EssentialsWebApi.cs | 6 ++++++ src/PepperDash.Essentials/ControlSystem.cs | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index 9e781883..375efdeb 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -20,6 +20,8 @@ namespace PepperDash.Essentials.Core.Web private readonly WebApiServer _debugServer; + + /// /// http(s)://{ipaddress}/cws/{basePath} /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} @@ -301,7 +303,11 @@ namespace PepperDash.Essentials.Core.Web { 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, "Developer Tools Web App available at: https://{currentIp}/cws/debug", currentIp); } } } \ No newline at end of file diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index d4a7545a..fe7bf807 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -274,10 +274,9 @@ namespace PepperDash.Essentials PluginLoader.LoadPlugins(); Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config..."); - if (!ConfigReader.LoadConfig2()) + if (!ConfigReader.LoadConfig2() || ConfigReader.ConfigObject == null) { - Debug.LogMessage(LogEventLevel.Information, "Essentials Load complete with errors"); - return; + Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file. Please ensure a valid config file is present and restart the program."); } Load(); @@ -399,6 +398,12 @@ namespace PepperDash.Essentials new Core.Monitoring.SystemMonitorController("systemMonitor")); } + if (ConfigReader.ConfigObject is null) + { + Debug.LogMessage(LogEventLevel.Warning, "LoadDevices: ConfigObject is null. Cannot load devices."); + return; + } + foreach (var devConf in ConfigReader.ConfigObject.Devices) { IKeyed newDev = null; @@ -452,7 +457,7 @@ namespace PepperDash.Essentials var tlc = TieLineCollection.Default; - if (ConfigReader.ConfigObject.TieLines == null) + if (ConfigReader.ConfigObject?.TieLines == null) { return; } @@ -749,7 +754,7 @@ namespace PepperDash.Essentials /// public void LoadRooms() { - if (ConfigReader.ConfigObject.Rooms == null) + if (ConfigReader.ConfigObject?.Rooms == null) { Debug.LogMessage(LogEventLevel.Information, "Notice: Configuration contains no rooms - Is this intentional? This may be a valid configuration."); return; @@ -786,13 +791,13 @@ namespace PepperDash.Essentials /// void LoadLogoServer() { - if (ConfigReader.ConfigObject.Rooms == null) + if (ConfigReader.ConfigObject?.Rooms == null) { Debug.LogMessage(LogEventLevel.Information, "No rooms configured. Bypassing Logo server startup."); return; } - if ( + if (ConfigReader.ConfigObject?.Rooms == null || !ConfigReader.ConfigObject.Rooms.Any( CheckRoomConfig)) { From 2530003a58315fa325a83f1ced7eaf0522dcfa28 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 10:56:56 -0600 Subject: [PATCH 10/84] refactor: clean up whitespace and improve debug URL logging format --- src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index 375efdeb..3cdb8433 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -20,7 +20,7 @@ namespace PepperDash.Essentials.Core.Web private readonly WebApiServer _debugServer; - + /// /// http(s)://{ipaddress}/cws/{basePath} @@ -262,7 +262,7 @@ namespace PepperDash.Essentials.Core.Web _server.Start(); _debugServer.Start(); - + GetPaths(); } @@ -307,7 +307,11 @@ namespace PepperDash.Essentials.Core.Web Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); - Debug.LogMessage(LogEventLevel.Information, this, "Developer Tools Web App available at: https://{currentIp}/cws/debug", currentIp); + var debugAppUrl = CrestronEnvironment.DevicePlatform == eDevicePlatform.Server + ? $"https://{hostname}/VirtualControl/Rooms/{InitialParametersClass.RoomId}/cws/debug" + : $"https://{currentIp}/cws/debug"; + + Debug.LogMessage(LogEventLevel.Information, this, "Developer Tools Web App available at: {debugAppUrl:l}", debugAppUrl); } } } \ No newline at end of file From c050bb4eb342440c00a3acdecc42d5da496304d6 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 11:01:10 -0600 Subject: [PATCH 11/84] feat: disable key persistence for RSA in DebugWebsocketSink and simplify config loading warning message --- src/PepperDash.Core/Logging/DebugWebsocketSink.cs | 1 + src/PepperDash.Essentials/ControlSystem.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 1f4dda7a..8986fcb4 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -273,6 +273,7 @@ namespace PepperDash.Core var rsaParams = DotNetUtilities.ToRSAParameters( (RsaPrivateCrtKeyParameters)keyEntry.Key); var rsa = new RSACryptoServiceProvider(); + rsa.PersistKeyInCsp = false; rsa.ImportParameters(rsaParams); cert.PrivateKey = rsa; diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index fe7bf807..db057cbf 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -276,7 +276,7 @@ namespace PepperDash.Essentials Debug.LogMessage(LogEventLevel.Information, "Folder structure verified. Loading config..."); if (!ConfigReader.LoadConfig2() || ConfigReader.ConfigObject == null) { - Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file. Please ensure a valid config file is present and restart the program."); + Debug.LogMessage(LogEventLevel.Warning, "Unable to load config file."); } Load(); From 404728c708bb6f9750c1b30102cd3db57eea2fb8 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 12:07:29 -0600 Subject: [PATCH 12/84] feat: refactor portal info command to improve configuration handling and response messages --- src/PepperDash.Essentials/ControlSystem.cs | 38 ++++++++++++++-------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index db057cbf..5a0ba2af 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -130,14 +130,8 @@ namespace PepperDash.Essentials (ConfigReader.ConfigObject, Newtonsoft.Json.Formatting.Indented).Replace(Environment.NewLine, "\r\n")); }, "showconfig", "Shows the current running merged config", ConsoleAccessLevelEnum.AccessOperator); - CrestronConsole.AddNewConsoleCommand(s => - CrestronConsole.ConsoleCommandResponse( - "This system can be found at the following URLs:{2}" + - "System URL: {0}{2}" + - "Template URL: {1}{2}", - ConfigReader.ConfigObject.SystemUrl, - ConfigReader.ConfigObject.TemplateUrl, - CrestronEnvironment.NewLine), + CrestronConsole.AddNewConsoleCommand( + PrintPortalInfo, "portalinfo", "Shows portal URLS from configuration", ConsoleAccessLevelEnum.AccessOperator); @@ -160,6 +154,29 @@ namespace PepperDash.Essentials } } + private void PrintPortalInfo(string args) + { + if(ConfigReader.ConfigObject == null) + { + CrestronConsole.ConsoleCommandResponse("No configuration loaded. Cannot show portal URLs."); + return; + } + + if (string.IsNullOrEmpty(ConfigReader.ConfigObject.SystemUrl) && string.IsNullOrEmpty(ConfigReader.ConfigObject.TemplateUrl)) + { + CrestronConsole.ConsoleCommandResponse("No portal URLs defined in config."); + return; + } + + CrestronConsole.ConsoleCommandResponse( + "This system can be found at the following URLs:{2}" + + "System URL: {0}{2}" + + "Template URL: {1}{2}", + ConfigReader.ConfigObject?.SystemUrl, + ConfigReader.ConfigObject?.TemplateUrl, + CrestronEnvironment.NewLine); + } + /// /// DeterminePlatform method /// @@ -257,11 +274,6 @@ namespace PepperDash.Essentials PluginLoader.AddProgramAssemblies(); _ = new Core.DeviceFactory(); - // _ = new Devices.Common.DeviceFactory(); - // _ = new DeviceFactory(); - - // _ = new ProcessorExtensionDeviceFactory(); - // _ = new MobileControlFactory(); LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix); From 9fc866741e8c8ef6f34f1e1bb0139416f1337bed Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 8 May 2026 13:08:58 -0600 Subject: [PATCH 13/84] feat: mark deprecated classes and methods for future removal --- src/PepperDash.Core/Logging/DebugContext.cs | 1 + .../WebApi/Presets/WebApiPasscodeClient.cs | 211 +++++++++--------- .../Config/Essentials/ConfigUpdater.cs | 25 ++- .../Interfaces/ILogStrings.cs | 7 +- .../Interfaces/ILogStringsWithLevel.cs | 9 +- .../Routing/eRoutingSignalType.cs | 3 + .../Cameras/CameraVisca.cs | 2 + .../Messengers/DeviceStateMessageBase.cs | 2 + src/PepperDash.Essentials/ControlSystem.cs | 1 + 9 files changed, 137 insertions(+), 124 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugContext.cs b/src/PepperDash.Core/Logging/DebugContext.cs index e90ec86a..66e63634 100644 --- a/src/PepperDash.Core/Logging/DebugContext.cs +++ b/src/PepperDash.Core/Logging/DebugContext.cs @@ -11,6 +11,7 @@ namespace PepperDash.Core /// /// Represents a debugging context /// + [Obsolete("DebugContext is no longer supported and will be removed in a future release.")] public class DebugContext { /// diff --git a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs index f8c815fd..68bbe362 100644 --- a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs +++ b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs @@ -1,5 +1,5 @@ using System; -using Crestron.SimplSharp; // For Basic SIMPL# Classes +using Crestron.SimplSharp; // For Basic SIMPL# Classes using Crestron.SimplSharp.CrestronIO; using Crestron.SimplSharp.Net.Http; using Crestron.SimplSharp.Net.Https; @@ -10,24 +10,25 @@ using PepperDash.Core.JsonToSimpl; namespace PepperDash.Core.WebApi.Presets { - /// - /// Passcode client for the WebApi - /// + /// + /// Passcode client for the WebApi + /// + [Obsolete("WebApiPasscodeClient is no longer supported and will be removed in a future release.")] public class WebApiPasscodeClient : IKeyed { - /// - /// Notifies when user received - /// + /// + /// Notifies when user received + /// public event EventHandler UserReceived; - /// - /// Notifies when Preset received - /// + /// + /// Notifies when Preset received + /// public event EventHandler PresetReceived; - /// - /// Gets or sets the Key - /// + /// + /// Gets or sets the Key + /// public string Key { get; private set; } //string JsonMasterKey; @@ -54,13 +55,13 @@ namespace PepperDash.Core.WebApi.Presets { } - /// - /// Initializes the instance - /// - /// - /// - /// - /// + /// + /// Initializes the instance + /// + /// + /// + /// + /// public void Initialize(string key, string jsonMasterKey, string urlBase, string defaultPresetJsonFilePath) { Key = key; @@ -73,44 +74,44 @@ namespace PepperDash.Core.WebApi.Presets J2SMaster.Initialize(jsonMasterKey); } - /// - /// Gets the user for a passcode - /// - /// - /// - /// GetUserForPasscode method - /// + /// + /// Gets the user for a passcode + /// + /// + /// + /// GetUserForPasscode method + /// public void GetUserForPasscode(string passcode) { - // Bullshit duplicate code here... These two cases should be the same - // except for https/http and the certificate ignores - if (!UrlBase.StartsWith("https")) - return; - var req = new HttpsClientRequest(); - req.Url = new UrlParser(UrlBase + "/api/users/dopin"); - req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; - req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); - req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); - var jo = new JObject(); - jo.Add("pin", passcode); - req.ContentString = jo.ToString(); + // Bullshit duplicate code here... These two cases should be the same + // except for https/http and the certificate ignores + if (!UrlBase.StartsWith("https")) + return; + var req = new HttpsClientRequest(); + req.Url = new UrlParser(UrlBase + "/api/users/dopin"); + req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; + req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); + req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); + var jo = new JObject(); + jo.Add("pin", passcode); + req.ContentString = jo.ToString(); - var client = new HttpsClient(); - client.HostVerification = false; - client.PeerVerification = false; - var resp = client.Dispatch(req); - var handler = UserReceived; - if (resp.Code == 200) - { - //CrestronConsole.PrintLine("Received: {0}", resp.ContentString); - var user = JsonConvert.DeserializeObject(resp.ContentString); - CurrentUser = user; - if (handler != null) - UserReceived(this, new UserReceivedEventArgs(user, true)); - } - else - if (handler != null) - UserReceived(this, new UserReceivedEventArgs(null, false)); + var client = new HttpsClient(); + client.HostVerification = false; + client.PeerVerification = false; + var resp = client.Dispatch(req); + var handler = UserReceived; + if (resp.Code == 200) + { + //CrestronConsole.PrintLine("Received: {0}", resp.ContentString); + var user = JsonConvert.DeserializeObject(resp.ContentString); + CurrentUser = user; + if (handler != null) + UserReceived(this, new UserReceivedEventArgs(user, true)); + } + else + if (handler != null) + UserReceived(this, new UserReceivedEventArgs(null, false)); } /// @@ -118,9 +119,9 @@ namespace PepperDash.Core.WebApi.Presets /// /// /// - /// - /// GetPresetForThisUser method - /// + /// + /// GetPresetForThisUser method + /// public void GetPresetForThisUser(int roomTypeId, int presetNumber) { if (CurrentUser == null) @@ -136,57 +137,57 @@ namespace PepperDash.Core.WebApi.Presets PresetNumber = presetNumber }; - var handler = PresetReceived; + var handler = PresetReceived; try { - if (!UrlBase.StartsWith("https")) - return; - var req = new HttpsClientRequest(); - req.Url = new UrlParser(UrlBase + "/api/presets/userandroom"); - req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; - req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); - req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); - req.ContentString = JsonConvert.SerializeObject(msg); + if (!UrlBase.StartsWith("https")) + return; + var req = new HttpsClientRequest(); + req.Url = new UrlParser(UrlBase + "/api/presets/userandroom"); + req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; + req.Header.AddHeader(new HttpsHeader("Content-Type", "application/json")); + req.Header.AddHeader(new HttpsHeader("Accept", "application/json")); + req.ContentString = JsonConvert.SerializeObject(msg); - var client = new HttpsClient(); - client.HostVerification = false; - client.PeerVerification = false; + var client = new HttpsClient(); + client.HostVerification = false; + client.PeerVerification = false; - // ask for the preset - var resp = client.Dispatch(req); - if (resp.Code == 200) // got it - { - //Debug.Console(1, this, "Received: {0}", resp.ContentString); - var preset = JsonConvert.DeserializeObject(resp.ContentString); - CurrentPreset = preset; + // ask for the preset + var resp = client.Dispatch(req); + if (resp.Code == 200) // got it + { + //Debug.Console(1, this, "Received: {0}", resp.ContentString); + var preset = JsonConvert.DeserializeObject(resp.ContentString); + CurrentPreset = preset; - //if there's no preset data, load the template - if (preset.Data == null || preset.Data.Trim() == string.Empty || JObject.Parse(preset.Data).Count == 0) - { - //Debug.Console(1, this, "Loaded preset has no data. Loading default template."); - LoadDefaultPresetData(); - return; - } + //if there's no preset data, load the template + if (preset.Data == null || preset.Data.Trim() == string.Empty || JObject.Parse(preset.Data).Count == 0) + { + //Debug.Console(1, this, "Loaded preset has no data. Loading default template."); + LoadDefaultPresetData(); + return; + } - J2SMaster.LoadWithJson(preset.Data); - if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(preset, true)); - } - else // no existing preset - { - CurrentPreset = new Preset(); - LoadDefaultPresetData(); - if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(null, false)); - } + J2SMaster.LoadWithJson(preset.Data); + if (handler != null) + PresetReceived(this, new PresetReceivedEventArgs(preset, true)); + } + else // no existing preset + { + CurrentPreset = new Preset(); + LoadDefaultPresetData(); + if (handler != null) + PresetReceived(this, new PresetReceivedEventArgs(null, false)); + } } catch (HttpException e) { var resp = e.Response; Debug.Console(1, this, "No preset received (code {0}). Loading default template", resp.Code); LoadDefaultPresetData(); - if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(null, false)); + if (handler != null) + PresetReceived(this, new PresetReceivedEventArgs(null, false)); } } @@ -218,14 +219,14 @@ namespace PepperDash.Core.WebApi.Presets /// /// /// - /// - /// SavePresetForThisUser method - /// + /// + /// SavePresetForThisUser method + /// public void SavePresetForThisUser(int roomTypeId, int presetNumber) { if (CurrentPreset == null) LoadDefaultPresetData(); - //return; + //return; //// A new preset needs to have its numbers set //if (CurrentPreset.IsNewPreset) @@ -245,8 +246,8 @@ namespace PepperDash.Core.WebApi.Presets { CurrentPreset.Data = json; - if (!UrlBase.StartsWith("https")) - return; + if (!UrlBase.StartsWith("https")) + return; var req = new HttpsClientRequest(); req.RequestType = Crestron.SimplSharp.Net.Https.RequestType.Post; req.Url = new UrlParser(string.Format("{0}/api/presets/addorchange", UrlBase)); @@ -255,8 +256,8 @@ namespace PepperDash.Core.WebApi.Presets req.ContentString = JsonConvert.SerializeObject(CurrentPreset); var client = new HttpsClient(); - client.HostVerification = false; - client.PeerVerification = false; + client.HostVerification = false; + client.PeerVerification = false; try { var resp = client.Dispatch(req); diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs index 9df673bf..e940c573 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs @@ -19,6 +19,7 @@ namespace PepperDash.Essentials.Core.Config /// /// ConfigUpdater class /// + [Obsolete("ConfigUpdater is no longer supported and will be removed in a future release.")] public static class ConfigUpdater { /// @@ -81,7 +82,7 @@ namespace PepperDash.Essentials.Core.Config { var handler = ConfigStatusChanged; - if(handler != null) + if (handler != null) { handler(typeof(ConfigUpdater), new ConfigStatusEventArgs(status)); } @@ -89,7 +90,7 @@ namespace PepperDash.Essentials.Core.Config static void WriteConfigToFile(string configData) { - var filePath = Global.FilePathPrefix+ "configurationFile-updated.json"; + var filePath = Global.FilePathPrefix + "configurationFile-updated.json"; try { @@ -104,7 +105,7 @@ namespace PepperDash.Essentials.Core.Config Debug.LogMessage(LogEventLevel.Debug, "Error parsing new config: {0}", e); OnStatusUpdate(eUpdateStatus.UpdateFailed); - } + } } /// @@ -149,11 +150,11 @@ namespace PepperDash.Essentials.Core.Config // Directory exists, first clear any contents var archivedConfigFiles = ConfigReader.GetConfigFiles(archiveDirectoryPath + Global.DirectorySeparator + Global.ConfigFileName + ".bak"); - if(archivedConfigFiles != null || archivedConfigFiles.Length > 0) + if (archivedConfigFiles != null || archivedConfigFiles.Length > 0) { Debug.LogMessage(LogEventLevel.Information, "{0} Existing files found in archive folder. Deleting.", archivedConfigFiles.Length); - for (int i = 0; i < archivedConfigFiles.Length; i++ ) + for (int i = 0; i < archivedConfigFiles.Length; i++) { var file = archivedConfigFiles[i]; Debug.LogMessage(LogEventLevel.Information, "Deleting archived file: '{0}'", file.FullName); @@ -170,9 +171,9 @@ namespace PepperDash.Essentials.Core.Config // Moves the file and appends the .bak extension var fileDest = archiveDirectoryPath + "/" + file.Name + ".bak"; - if(!File.Exists(fileDest)) + if (!File.Exists(fileDest)) { - file.MoveTo(fileDest); + file.MoveTo(fileDest); } else Debug.LogMessage(LogEventLevel.Information, "Cannot move file to archive folder. Existing file already exists with same name: '{0}'", fileDest); @@ -207,15 +208,15 @@ namespace PepperDash.Essentials.Core.Config CrestronConsole.SendControlSystemCommand(string.Format("progreset -p:{0}", InitialParametersClass.ApplicationNumber), ref response); - Debug.LogMessage(LogEventLevel.Debug, "Console Response: {0}", response); + Debug.LogMessage(LogEventLevel.Debug, "Console Response: {0}", response); } } - /// - /// Enumeration of eUpdateStatus values - /// - public enum eUpdateStatus + /// + /// Enumeration of eUpdateStatus values + /// + public enum eUpdateStatus { /// /// UpdateStarted status diff --git a/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs b/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs index d55194fd..b41a9020 100644 --- a/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs +++ b/src/PepperDash.Essentials.Core/Interfaces/ILogStrings.cs @@ -7,9 +7,10 @@ using PepperDash.Core; namespace PepperDash.Essentials.Core.Interfaces { - /// - /// Defines the contract for ILogStrings - /// + /// + /// Defines the contract for ILogStrings + /// + [Obsolete("ILogStrings is no longer supported and will be removed in a future release.")] public interface ILogStrings : IKeyed { /// diff --git a/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs b/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs index bb835919..32ef104a 100644 --- a/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs +++ b/src/PepperDash.Essentials.Core/Interfaces/ILogStringsWithLevel.cs @@ -7,15 +7,16 @@ using PepperDash.Core; namespace PepperDash.Essentials.Core.Interfaces { - /// - /// Defines the contract for ILogStringsWithLevel - /// + /// + /// Defines the contract for ILogStringsWithLevel + /// + [Obsolete("ILogStringsWithLevel is no longer supported and will be removed in a future release.")] public interface ILogStringsWithLevel : IKeyed { /// /// Defines a class that is capable of logging a string with an int level /// - void SendToLog(IKeyed device, Debug.ErrorLogLevel level,string logMessage); + void SendToLog(IKeyed device, Debug.ErrorLogLevel level, string logMessage); } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs b/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs index 9fce9c5b..708b7448 100644 --- a/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs +++ b/src/PepperDash.Essentials.Core/Routing/eRoutingSignalType.cs @@ -27,16 +27,19 @@ namespace PepperDash.Essentials.Core /// /// Control signal type /// + [Obsolete("UsbOutput is no longer supported and will be removed in a future release.")] UsbOutput = 8, /// /// Control signal type /// + [Obsolete("UsbInput is no longer supported and will be removed in a future release.")] UsbInput = 16, /// /// Secondary audio signal type /// + [Obsolete("SecondaryAudio is no longer supported and will be removed in a future release.")] SecondaryAudio = 32 } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs b/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs index 126bcd27..f383dd0a 100644 --- a/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs +++ b/src/PepperDash.Essentials.Devices.Common/Cameras/CameraVisca.cs @@ -17,6 +17,8 @@ namespace PepperDash.Essentials.Devices.Common.Cameras /// /// Represents a CameraVisca /// + [Obsolete("CameraVisca is no longer supported and will be removed in a future release. Use the CameraVisca plugin instead.")] + public class CameraVisca : CameraBase, IHasCameraPtzControl, ICommunicationMonitor, IHasCameraPresets, IHasPowerControlWithFeedback, IBridgeAdvanced, IHasCameraFocusControl, IHasAutoFocusMode { private readonly CameraViscaPropertiesConfig PropertiesConfig; diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs index 87f19e3f..a5df51a8 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Newtonsoft.Json; @@ -18,6 +19,7 @@ namespace PepperDash.Essentials.AppServer.Messengers /// Sets the interfaces implemented by the device sending the message /// /// + [Obsolete("SetInterfaces is no longer supported and will be removed in a future release. Interfaces for all devices are now retrieved via the /joinroom endpoint in the MobileControlWebsocketServer")] public void SetInterfaces(List interfaces) { Interfaces = interfaces; diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index 5a0ba2af..47de4d3e 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -801,6 +801,7 @@ namespace PepperDash.Essentials /// /// Fires up a logo server if not already running /// + [Obsolete("Logo server is no longer supported and will be removed in a future release.")] void LoadLogoServer() { if (ConfigReader.ConfigObject?.Rooms == null) From 8ba993ed668e741de3f63759e40c15efa8e28473 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 8 May 2026 13:19:04 -0600 Subject: [PATCH 14/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Config/Essentials/ConfigUpdater.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs index e940c573..2ba33df0 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigUpdater.cs @@ -150,7 +150,7 @@ namespace PepperDash.Essentials.Core.Config // Directory exists, first clear any contents var archivedConfigFiles = ConfigReader.GetConfigFiles(archiveDirectoryPath + Global.DirectorySeparator + Global.ConfigFileName + ".bak"); - if (archivedConfigFiles != null || archivedConfigFiles.Length > 0) + if (archivedConfigFiles != null && archivedConfigFiles.Length > 0) { Debug.LogMessage(LogEventLevel.Information, "{0} Existing files found in archive folder. Deleting.", archivedConfigFiles.Length); From f1ef479301cbcd724da2165a91b02b6174f3d0ea Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 8 May 2026 13:19:46 -0600 Subject: [PATCH 15/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs index 68bbe362..125da1ec 100644 --- a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs +++ b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs @@ -171,14 +171,14 @@ namespace PepperDash.Core.WebApi.Presets J2SMaster.LoadWithJson(preset.Data); if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(preset, true)); + handler(this, new PresetReceivedEventArgs(preset, true)); } else // no existing preset { CurrentPreset = new Preset(); LoadDefaultPresetData(); if (handler != null) - PresetReceived(this, new PresetReceivedEventArgs(null, false)); + handler(this, new PresetReceivedEventArgs(null, false)); } } catch (HttpException e) From 2607180ab5351108fe43913a366e96479d34be52 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 8 May 2026 13:20:03 -0600 Subject: [PATCH 16/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs index 125da1ec..131430a2 100644 --- a/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs +++ b/src/PepperDash.Core/WebApi/Presets/WebApiPasscodeClient.cs @@ -107,11 +107,11 @@ namespace PepperDash.Core.WebApi.Presets var user = JsonConvert.DeserializeObject(resp.ContentString); CurrentUser = user; if (handler != null) - UserReceived(this, new UserReceivedEventArgs(user, true)); + handler(this, new UserReceivedEventArgs(user, true)); } else if (handler != null) - UserReceived(this, new UserReceivedEventArgs(null, false)); + handler(this, new UserReceivedEventArgs(null, false)); } /// From e5e1802da996433b4761fdc58c623cf13d787f93 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Sun, 10 May 2026 20:48:05 -0600 Subject: [PATCH 17/84] feat: enhance LoginRequestHandler to include detailed LoginResponse structure Co-authored-by: Copilot --- .../Logging/DebugWebsocketSink.cs | 35 ++++--------- .../RequestHandlers/LoginRequestHandler.cs | 52 ++++++++++++++++++- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 8986fcb4..99910ec9 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -15,12 +15,10 @@ using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.Crypto.Generators; using Org.BouncyCastle.Crypto.Operators; -using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Math; using Org.BouncyCastle.Pkcs; using Org.BouncyCastle.Security; using Org.BouncyCastle.X509; -using System.Security.Cryptography; using Serilog.Formatting; using Serilog.Formatting.Json; @@ -244,9 +242,10 @@ namespace PepperDash.Core /// /// Loads a PKCS#12 file written by BouncyCastle and returns an with - /// private key attached via . - /// Using BouncyCastle's own reader avoids the .NET/Mono PFX parser, which can reject - /// BouncyCastle-generated archives on the Crestron runtime. + /// private key attached. + /// The PFX is parsed and re-encoded by BouncyCastle (ensuring format compatibility), then passed as + /// raw bytes to so neither RSACryptoServiceProvider nor the + /// EphemeralKeySet flag (unsupported on the Crestron/Mono runtime) is needed. /// private static X509Certificate2 LoadCertFromBouncyCastle(string certPath, string certPassword) { @@ -258,26 +257,12 @@ namespace PepperDash.Core var store = new Pkcs12StoreBuilder().Build(); store.Load(stream, passwordChars); - foreach (string alias in store.Aliases) + // Re-encode through BouncyCastle to guarantee PKCS#12 format compatibility, + // then hand raw bytes to X509Certificate2 — no RSACryptoServiceProvider needed. + using (var ms = new MemoryStream()) { - if (!store.IsKeyEntry(alias)) continue; - - var keyEntry = store.GetKey(alias); - var certChain = store.GetCertificateChain(alias); - if (certChain == null || certChain.Length == 0) continue; - - // Build X509Certificate2 from raw DER — no PFX parsing by .NET needed. - var cert = new X509Certificate2(certChain[0].Certificate.GetEncoded()); - - // Attach the private key via RSACryptoServiceProvider (available on all target runtimes). - var rsaParams = DotNetUtilities.ToRSAParameters( - (RsaPrivateCrtKeyParameters)keyEntry.Key); - var rsa = new RSACryptoServiceProvider(); - rsa.PersistKeyInCsp = false; - rsa.ImportParameters(rsaParams); - cert.PrivateKey = rsa; - - return cert; + store.Save(ms, passwordChars, new SecureRandom()); + return new X509Certificate2(ms.ToArray(), certPassword); } } } @@ -285,8 +270,6 @@ namespace PepperDash.Core { Array.Clear(passwordChars, 0, passwordChars.Length); } - - throw new InvalidOperationException("No key entry found in PKCS#12 store: " + certPath); } private void Start(int port, string certPath = "", string certPassword = "") diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index 5a4b7df7..c05e0cb6 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -1,5 +1,6 @@ using System; +using System.Collections.Generic; using Crestron.SimplSharp.CrestronAuthentication; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; @@ -91,7 +92,15 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers context.Response.StatusDescription = "OK"; context.Response.ContentType = "application/json"; context.Response.ContentEncoding = System.Text.Encoding.UTF8; - context.Response.Write(JsonConvert.SerializeObject(new { Token = token }, Formatting.Indented), false); + context.Response.Write(JsonConvert.SerializeObject( + new LoginResponse + { UserName = token.UserName, + Password = token.Password, + 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) @@ -121,4 +130,45 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// public string Password { get; set; } } + + /// + /// Represents a LoginResponse + /// + internal class LoginResponse + { + /// + /// Gets or sets the username. + /// + public string UserName { get; set; } + + /// + /// Gets or sets the password. + /// + public string Password { get; set; } + + /// + /// Gets or sets the access level. + /// + public Authentication.UserAuthenticationLevelEnum Access { get; set; } + + /// + /// Gets or sets the token authenticated state. + /// + public Authentication.eTokenAuthenticatedState State { get; set; } + + /// + /// Gets or sets the list of groups. + /// + public List Groups { get; set; } + + /// + /// Gets or sets the active directory connection flag. + /// + public int ADConnect { get; set; } + + /// + /// Gets or sets the valid flag indicating whether the token is valid. + /// + public bool Valid { get; set; } + } } \ No newline at end of file From 1a5840c29a166425baab9f95fbc94ddfa130c75c Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 11 May 2026 09:48:12 -0600 Subject: [PATCH 18/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Web/RequestHandlers/LoginRequestHandler.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index c05e0cb6..f70d1ddb 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -93,14 +93,15 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers context.Response.ContentType = "application/json"; context.Response.ContentEncoding = System.Text.Encoding.UTF8; context.Response.Write(JsonConvert.SerializeObject( - new LoginResponse - { UserName = token.UserName, - Password = token.Password, - Access = token.Access, - State = token.State, - Groups = token.Groups, - ADConnect = token.ADConnect, - Valid = token.Valid }, Formatting.Indented), false); + new + { + 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) From d879430616fad5255941d50fca218e9923f56020 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 11 May 2026 09:50:39 -0600 Subject: [PATCH 19/84] feat: update LoginResponse structure to include Password in the token response --- .../RequestHandlers/LoginRequestHandler.cs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index f70d1ddb..3d9fdac3 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -93,15 +93,19 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers context.Response.ContentType = "application/json"; context.Response.ContentEncoding = System.Text.Encoding.UTF8; context.Response.Write(JsonConvert.SerializeObject( - new - { - UserName = token.UserName, - Access = token.Access, - State = token.State, - Groups = token.Groups, - ADConnect = token.ADConnect, - Valid = token.Valid - }, Formatting.Indented), false); + new + { + Token = new LoginResponse + { + UserName = token.UserName, + Password = token.Password, + 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) From a9dd57fdaf70b55f63bf7b4a3c2e2088aee244e7 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 11 May 2026 09:51:48 -0600 Subject: [PATCH 20/84] refactor: remove Password from LoginResponse and related token assignment --- .../Web/RequestHandlers/LoginRequestHandler.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs index 3d9fdac3..949f8bd5 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -98,7 +98,6 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers Token = new LoginResponse { UserName = token.UserName, - Password = token.Password, Access = token.Access, State = token.State, Groups = token.Groups, @@ -146,11 +145,6 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// public string UserName { get; set; } - /// - /// Gets or sets the password. - /// - public string Password { get; set; } - /// /// Gets or sets the access level. /// From b318e7f365d5e405d84a62ab5164add4fde4ee64 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Mon, 11 May 2026 09:55:27 -0600 Subject: [PATCH 21/84] fix: validate certificate for private key presence in DebugWebsocketSink Co-authored-by: Copilot --- src/PepperDash.Core/Logging/DebugWebsocketSink.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 99910ec9..eeba5772 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -262,7 +262,13 @@ namespace PepperDash.Core using (var ms = new MemoryStream()) { store.Save(ms, passwordChars, new SecureRandom()); - return new X509Certificate2(ms.ToArray(), certPassword); + 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; } } } From c9f5af184b301c03f34cd8990ed9019977e57114 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 1 May 2026 14:29:54 -0700 Subject: [PATCH 22/84] feat: add support for UdpClient in communication methods and implement GenericUdpClient class --- docs/docs/usage/GenericComm.md | 7 +- src/PepperDash.Core/Comm/GenericUdpClient.cs | 388 ++++++++++++++++++ src/PepperDash.Core/Comm/eControlMethods.cs | 4 + .../Comm and IR/CommFactory.cs | 11 + 4 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 src/PepperDash.Core/Comm/GenericUdpClient.cs diff --git a/docs/docs/usage/GenericComm.md b/docs/docs/usage/GenericComm.md index 243536e5..1c1642a8 100644 --- a/docs/docs/usage/GenericComm.md +++ b/docs/docs/usage/GenericComm.md @@ -183,11 +183,12 @@ namespace PepperDash.Core Cresnet = 8, Cec = 9, Udp = 10, + UdpClient = 11, } } ``` -These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, and ```Udp```. + These enumerations are not case sensitive. Not all methods are valid for a ```genericComm``` device. For a comport, the only valid type would be ```Com```. For a direct network socket, valid options are ```Ssh```, ```Tcpip```, ```Telnet```, ```UdpClient```, and ```Udp```. ##### ComParams @@ -287,7 +288,7 @@ This property maps to the number of the port on the device you have mapped the r ##### TcpSshParams -A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket. +A ```Ssh```, ```TcpIp```, ```UdpClient```, or ```Udp``` device requires a ```tcpSshProperties``` object to set the propeties of the socket. ```Json { @@ -304,7 +305,7 @@ A ```Ssh```, ```TcpIp```, or ```Udp``` device requires a ```tcpSshProperties``` **```address```** -This is the IP address, hostname, or FQDN of the resource you wish to open a socket to. In the case of a UDP device, you can set either a single whitelist address with this data, or an appropriate broadcast address. +This is the IP address, hostname, or FQDN of the resource you wish to open a socket to. Use ```UdpClient``` for outbound UDP to a remote endpoint. Use ```Udp``` when you need Essentials to bind a local UDP listener. **```port```** diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs new file mode 100644 index 00000000..95954eea --- /dev/null +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -0,0 +1,388 @@ +using System; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronSockets; +using ThreadingTimeout = System.Threading.Timeout; +using NetSocketException = System.Net.Sockets.SocketException; + +namespace PepperDash.Core +{ + /// + /// A class to handle basic UDP communications to a remote endpoint + /// + public class GenericUdpClient : Device, ISocketStatusWithStreamDebugging, IAutoReconnect + { + private const string SplusKey = "Uninitialized UdpClient"; + + private readonly object stateLock = new object(); + private readonly Timer reconnectTimer; + + private UdpClient client; + private CancellationTokenSource receiveCancellationTokenSource; + private bool connectEnabled; + private SocketStatus clientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + + /// + /// Object to enable stream debugging + /// + public CommunicationStreamDebugging StreamDebugging { get; private set; } + + /// + /// Fires when data is received from the remote endpoint and returns it as a byte array + /// + public event EventHandler BytesReceived; + + /// + /// Fires when data is received from the remote endpoint and returns it as text + /// + public event EventHandler TextReceived; + + /// + /// Fires when the socket status changes + /// + public event EventHandler ConnectionChange; + + /// + /// Address of remote endpoint + /// + public string Hostname { get; set; } + + /// + /// Port on remote endpoint + /// + public int Port { get; set; } + + /// + /// Another S+ helper because large port numbers can be treated as signed ints + /// + public ushort UPort + { + get { return Convert.ToUInt16(Port); } + set { Port = Convert.ToInt32(value); } + } + + /// + /// Defaults to 2000 + /// + public int BufferSize { get; set; } + + /// + /// True when the local socket is created and associated with the configured remote endpoint + /// + public bool IsConnected + { + get { return ClientStatus == SocketStatus.SOCKET_STATUS_CONNECTED; } + } + + /// + /// S+ helper for IsConnected + /// + public ushort UIsConnected + { + get { return (ushort)(IsConnected ? 1 : 0); } + } + + /// + /// The current socket status of the client + /// + public SocketStatus ClientStatus + { + get + { + lock (stateLock) + { + return clientStatus; + } + } + private set + { + var shouldFireEvent = false; + + lock (stateLock) + { + if (clientStatus != value) + { + clientStatus = value; + shouldFireEvent = true; + } + } + + if (shouldFireEvent) + ConnectionChange?.Invoke(this, new GenericSocketStatusChageEventArgs(this)); + } + } + + /// + /// Ushort representation of client status + /// + public ushort UStatus + { + get { return (ushort)ClientStatus; } + } + + /// + /// Gets or sets the AutoReconnect + /// + public bool AutoReconnect { get; set; } + + /// + /// S+ helper for AutoReconnect + /// + public ushort UAutoReconnect + { + get { return (ushort)(AutoReconnect ? 1 : 0); } + set { AutoReconnect = value == 1; } + } + + /// + /// Milliseconds to wait before attempting to reconnect. Defaults to 5000 + /// + public int AutoReconnectIntervalMs { get; set; } + + /// + /// Constructor + /// + public GenericUdpClient(string key, string address, int port, int bufferSize) + : base(key) + { + StreamDebugging = new CommunicationStreamDebugging(key); + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + AutoReconnectIntervalMs = 5000; + Hostname = address; + Port = port; + BufferSize = bufferSize; + + reconnectTimer = new Timer(o => + { + if (connectEnabled) + Connect(); + }, null, ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + } + + /// + /// Constructor for S+ + /// + public GenericUdpClient() + : base(SplusKey) + { + StreamDebugging = new CommunicationStreamDebugging(SplusKey); + CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; + AutoReconnectIntervalMs = 5000; + BufferSize = 2000; + + reconnectTimer = new Timer(o => + { + if (connectEnabled) + Connect(); + }, null, ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + } + + /// + /// Initialize method + /// + public void Initialize(string key) + { + Key = key; + } + + private void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) + { + if (programEventType == eProgramStatusEventType.Stopping) + { + Debug.Console(1, this, "Program stopping. Closing connection"); + Deactivate(); + } + } + + /// + /// Deactivate method + /// + public override bool Deactivate() + { + Disconnect(); + return true; + } + + /// + /// Connect method + /// + public void Connect() + { + if (string.IsNullOrEmpty(Hostname)) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': No address set", Key); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + return; + } + + if (Port < 1 || Port > 65535) + { + Debug.Console(1, Debug.ErrorLogLevel.Warning, "GenericUdpClient '{0}': Invalid port", Key); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + return; + } + + lock (stateLock) + { + connectEnabled = true; + + if (client != null) + return; + + try + { + receiveCancellationTokenSource = new CancellationTokenSource(); + client = new UdpClient(); + client.Client.ReceiveBufferSize = BufferSize; + client.Client.SendBufferSize = BufferSize; + client.Connect(Hostname, Port); + ClientStatus = SocketStatus.SOCKET_STATUS_CONNECTED; + reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + StartReceive(receiveCancellationTokenSource.Token); + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Error connecting UDP client {0}", this, Key); + CleanupClient(); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + StartReconnectTimer(); + } + } + } + + /// + /// Disconnect method + /// + public void Disconnect() + { + lock (stateLock) + { + connectEnabled = false; + reconnectTimer.Change(ThreadingTimeout.Infinite, ThreadingTimeout.Infinite); + CleanupClient(); + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + } + } + + /// + /// SendText method + /// + public void SendText(string text) + { + var bytes = Encoding.GetEncoding(28591).GetBytes(text); + SendBytes(bytes); + } + + /// + /// SendBytes method + /// + public void SendBytes(byte[] bytes) + { + if (bytes == null) + return; + + try + { + this.PrintSentBytes(bytes); + + if (!IsConnected || client == null) + Connect(); + + var udpClient = client; + if (!IsConnected || udpClient == null) + 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; + + 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) + { + Debug.LogMessage(ex, "UDP receive error for {0}", this, Key); + HandleDisconnected(); + return; + } + catch (Exception ex) + { + Debug.LogMessage(ex, "Unexpected UDP receive error for {0}", this, Key); + HandleDisconnected(); + return; + } + } + }, 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; + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Core/Comm/eControlMethods.cs b/src/PepperDash.Core/Comm/eControlMethods.cs index b807fdc5..006bb987 100644 --- a/src/PepperDash.Core/Comm/eControlMethods.cs +++ b/src/PepperDash.Core/Comm/eControlMethods.cs @@ -56,6 +56,10 @@ namespace PepperDash.Core /// Udp, /// + /// UDP client + /// + UdpClient, + /// /// HTTP client /// Http, diff --git a/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs b/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs index 9318e04b..b28331a0 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CommFactory.cs @@ -96,6 +96,17 @@ namespace PepperDash.Essentials.Core comm = udp; break; } + case eControlMethod.UdpClient: + { + var udpClient = new GenericUdpClient(deviceConfig.Key + "-udpClient", c.Address, c.Port, c.BufferSize) + { + AutoReconnect = c.AutoReconnect + }; + if (udpClient.AutoReconnect) + udpClient.AutoReconnectIntervalMs = c.AutoReconnectIntervalMs; + comm = udpClient; + break; + } case eControlMethod.Telnet: break; case eControlMethod.SecureTcpIp: From b83af26b774e95b1e4042fc7ced1118b85c34470 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:01:08 -0700 Subject: [PATCH 23/84] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 65 ++++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index 95954eea..ec7f0e7f 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -225,30 +225,71 @@ namespace PepperDash.Core 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 + try + { + newReceiveCancellationTokenSource = new CancellationTokenSource(); + newClient = new UdpClient(); + newClient.Client.ReceiveBufferSize = bufferSize; + newClient.Client.SendBufferSize = bufferSize; + newClient.Connect(hostname, port); + + lock (stateLock) { - receiveCancellationTokenSource = new CancellationTokenSource(); - client = new UdpClient(); - client.Client.ReceiveBufferSize = BufferSize; - client.Client.SendBufferSize = BufferSize; - client.Connect(Hostname, Port); + 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); - StartReceive(receiveCancellationTokenSource.Token); + startReceiveToken = receiveCancellationTokenSource.Token; + shouldStartReceive = true; } - catch (Exception ex) + + 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) { - Debug.LogMessage(ex, "Error connecting UDP client {0}", this, Key); - CleanupClient(); - ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; - StartReconnectTimer(); + newReceiveCancellationTokenSource.Cancel(); + newReceiveCancellationTokenSource.Dispose(); + } + + lock (stateLock) + { + if (connectEnabled && client == null) + { + ClientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; + StartReconnectTimer(); + } } } } From e57bc43a109166575a93d0c8f64fe9af2e5331f7 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:04:28 -0700 Subject: [PATCH 24/84] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index ec7f0e7f..c9a54505 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -313,6 +313,8 @@ namespace PepperDash.Core /// public void SendText(string text) { + this.PrintSentText(text); + var bytes = Encoding.GetEncoding(28591).GetBytes(text); SendBytes(bytes); } From e6583f7824350699eb7caebdc61026cd8bb3694d Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:06:25 -0700 Subject: [PATCH 25/84] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index c9a54505..3d93bada 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -383,14 +383,26 @@ namespace PepperDash.Core catch (NetSocketException ex) { Debug.LogMessage(ex, "UDP receive error for {0}", this, Key); - HandleDisconnected(); - return; + + if (AutoReconnect) + { + HandleDisconnected(); + return; + } + + continue; } catch (Exception ex) { Debug.LogMessage(ex, "Unexpected UDP receive error for {0}", this, Key); - HandleDisconnected(); - return; + + if (AutoReconnect) + { + HandleDisconnected(); + return; + } + + continue; } } }, token); From 4f2d2ca746b23329065be95cb7bfcd890e5bac1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 20:09:51 +0000 Subject: [PATCH 26/84] fix: log udp client send failures when disconnected Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/761a7a78-c51f-474b-9000-baa9a232c0d0 Co-authored-by: jonnyarndt <21110580+jonnyarndt@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index 3d93bada..2c2d0f1c 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -336,7 +336,10 @@ namespace PepperDash.Core 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); } @@ -440,4 +443,4 @@ namespace PepperDash.Core } } } -} \ No newline at end of file +} From d47cfd5e62b864dccd47d8575c732e57fc980fe5 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:13:24 -0700 Subject: [PATCH 27/84] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/docs/usage/GenericComm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/usage/GenericComm.md b/docs/docs/usage/GenericComm.md index 1c1642a8..16f467e2 100644 --- a/docs/docs/usage/GenericComm.md +++ b/docs/docs/usage/GenericComm.md @@ -188,7 +188,7 @@ namespace PepperDash.Core } ``` - 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```, ```UdpClient```, and ```Udp```. ##### ComParams From 18f7000d76df69210298ba13544a233c6e52f98e Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Fri, 15 May 2026 13:45:50 -0700 Subject: [PATCH 28/84] fix: add udpClient behavior to throttle receive errors and reset upon valid traffic arrival --- src/PepperDash.Core/Comm/GenericUdpClient.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/PepperDash.Core/Comm/GenericUdpClient.cs b/src/PepperDash.Core/Comm/GenericUdpClient.cs index 2c2d0f1c..41792537 100644 --- a/src/PepperDash.Core/Comm/GenericUdpClient.cs +++ b/src/PepperDash.Core/Comm/GenericUdpClient.cs @@ -23,6 +23,7 @@ namespace PepperDash.Core private UdpClient client; private CancellationTokenSource receiveCancellationTokenSource; private bool connectEnabled; + private bool connectionRefusedLogged; private SocketStatus clientStatus = SocketStatus.SOCKET_STATUS_NO_CONNECT; /// @@ -367,6 +368,8 @@ namespace PepperDash.Core if (bytes == null || bytes.Length == 0) continue; + connectionRefusedLogged = false; + var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); this.PrintReceivedBytes(bytes); @@ -385,6 +388,20 @@ namespace PepperDash.Core } 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) From 68fe205504f14dc14cb30350b78b9e948eb8a30b Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Wed, 20 May 2026 15:58:46 -0600 Subject: [PATCH 29/84] fix: assign explicit values to eControlMethod enum members and add UdpClient entry as last value --- src/PepperDash.Core/Comm/eControlMethods.cs | 43 +++++++++++---------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/PepperDash.Core/Comm/eControlMethods.cs b/src/PepperDash.Core/Comm/eControlMethods.cs index 006bb987..a1a30734 100644 --- a/src/PepperDash.Core/Comm/eControlMethods.cs +++ b/src/PepperDash.Core/Comm/eControlMethods.cs @@ -18,74 +18,75 @@ namespace PepperDash.Core /// /// RS232/422/485 /// - Com, + Com = 1, /// /// Crestron IpId (most Crestron ethernet devices) /// - IpId, + IpId = 2, /// /// Crestron IpIdTcp (HD-MD series, etc.) /// - IpidTcp, + IpidTcp = 3, /// /// Crestron IR control /// - IR, + IR = 4, /// /// SSH client /// - Ssh, + Ssh = 5, /// /// TCP/IP client /// - Tcpip, + Tcpip = 6, /// /// Telnet /// - Telnet, + Telnet = 7, /// /// Crestnet device /// - Cresnet, + Cresnet = 8, /// /// CEC Control, via a DM HDMI port /// - Cec, + Cec = 9, /// /// UDP Server /// - Udp, - /// - /// UDP client - /// - UdpClient, + Udp = 10, + /// /// HTTP client /// - Http, + Http = 11, /// /// HTTPS client /// - Https, + Https = 12, /// /// Websocket client /// - Ws, + Ws = 13, /// /// Secure Websocket client /// - Wss, + Wss = 14, /// /// Secure TCP/IP /// - SecureTcpIp, + SecureTcpIp = 15, /// /// Used when comms needs to be handled in SIMPL and bridged opposite the normal direction /// - ComBridge, + ComBridge = 16, /// /// InfinetEX control /// - InfinetEx + InfinetEx = 17, + /// + /// UDP client + /// + UdpClient = 18, } } \ No newline at end of file From 08aba353342e2b5214a0d8af630d1fd756992852 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Wed, 20 May 2026 16:17:21 -0600 Subject: [PATCH 30/84] fix: addresses #1421 From 6587444970842af8aba769121979dbe11441e6b0 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 21 May 2026 13:11:39 -0600 Subject: [PATCH 31/84] fix: update routing logic and enhance logging in Extensions and MockVC classes --- .../Routing/Extensions.cs | 20 ++++++++++++------- .../VideoCodec/MockVC/MockVC.cs | 2 +- .../VideoCodec/VideoCodecBase.cs | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 631ebd99..be533b70 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -249,7 +249,7 @@ namespace PepperDash.Essentials.Core } // otherwise, audioVideo needs to be handled as two steps. - Debug.LogDebug(destination, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", source.Key, signalType); + Debug.LogDebug(destination, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", destination.Key, source.Key, signalType); RouteDescriptor audioRouteDescriptor; @@ -374,24 +374,28 @@ namespace PepperDash.Essentials.Core IndexTieLines(); } - var sinks = DeviceManager.AllDevices.OfType().Where(d => !(d is IRoutingInputsOutputs)); - var sources = DeviceManager.AllDevices.OfType().Where(d => !(d is IRoutingInputsOutputs)); + var sinks = DeviceManager.AllDevices.OfType(); + var sources = DeviceManager.AllDevices.OfType(); - foreach (var sink in sinks) + foreach (var sink in sinks.Where(d => !(d is IRoutingInputsOutputs))) { - foreach (var source in sources) + foreach (var source in sources.Where(d => !(d is IRoutingInputsOutputs))) { foreach (var inputPort in sink.InputPorts) { foreach (var outputPort in source.OutputPorts) { - var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, inputPort.Type, inputPort, outputPort); + var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, outputPort.Type, inputPort, outputPort); if (audioOrSingleRoute == null && videoRoute == null) { continue; } + Debug.LogVerbose("AudioOrSingleRoute Found: {audioRoute}", audioOrSingleRoute); + + Debug.LogVerbose("VideoRoute Found: {videoRoute}", videoRoute); + if (audioOrSingleRoute != null) { // Only add routes that have actual switching steps @@ -646,6 +650,8 @@ namespace PepperDash.Essentials.Core // Only the ones that are routing devices var midpointTieLines = destinationTieLines.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs); + Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count(), destination.Key); + //Create a list for tracking already checked devices to avoid loops, if it doesn't already exist from previous iteration if (alreadyCheckedDevices == null) alreadyCheckedDevices = new List(); @@ -685,7 +691,7 @@ namespace PepperDash.Essentials.Core if (goodInputPort == null) { - Debug.LogVerbose(destination, "No route found to {0}", source.Key); + Debug.LogVerbose(destination, "No route found to {0} from destination {1} for type {2}", source.Key, destination.Key, signalType); // Cache this as an impossible route _impossibleRoutes.TryAdd(routeKey, 0); diff --git a/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs b/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs index e9f7908c..bdefa09d 100644 --- a/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs +++ b/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs @@ -21,7 +21,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec /// /// Represents a MockVC /// - public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets + public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets, IRoutingInputs { /// /// Gets or sets the PropertiesConfig diff --git a/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs b/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs index b0b5ef55..04c7e64d 100644 --- a/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs +++ b/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs @@ -26,7 +26,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec /// /// Base class for video codec devices /// - public abstract class VideoCodecBase : ReconfigurableDevice, IRoutingInputsOutputs, + public abstract class VideoCodecBase : ReconfigurableDevice, IUsageTracking, IHasDialer, IHasContentSharing, ICodecAudio, iVideoCodecInfo, IBridgeAdvanced, IHasStandbyMode { private const int XSigEncoding = 28591; From f658fdf3631be6fe9fed69c684ff4e0818dd93b2 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 21 May 2026 16:21:03 -0600 Subject: [PATCH 32/84] fix: update routing logic and enhance logging in Extensions and MockVC classes closes #1423 --- .../Routing/Extensions.cs | 20 ++++++++++++------- .../VideoCodec/MockVC/MockVC.cs | 2 +- .../VideoCodec/VideoCodecBase.cs | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 631ebd99..be533b70 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -249,7 +249,7 @@ namespace PepperDash.Essentials.Core } // otherwise, audioVideo needs to be handled as two steps. - Debug.LogDebug(destination, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", source.Key, signalType); + Debug.LogDebug(destination, "Attempting to build source route from {destinationKey} to {sourceKey} of type {type}", destination.Key, source.Key, signalType); RouteDescriptor audioRouteDescriptor; @@ -374,24 +374,28 @@ namespace PepperDash.Essentials.Core IndexTieLines(); } - var sinks = DeviceManager.AllDevices.OfType().Where(d => !(d is IRoutingInputsOutputs)); - var sources = DeviceManager.AllDevices.OfType().Where(d => !(d is IRoutingInputsOutputs)); + var sinks = DeviceManager.AllDevices.OfType(); + var sources = DeviceManager.AllDevices.OfType(); - foreach (var sink in sinks) + foreach (var sink in sinks.Where(d => !(d is IRoutingInputsOutputs))) { - foreach (var source in sources) + foreach (var source in sources.Where(d => !(d is IRoutingInputsOutputs))) { foreach (var inputPort in sink.InputPorts) { foreach (var outputPort in source.OutputPorts) { - var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, inputPort.Type, inputPort, outputPort); + var (audioOrSingleRoute, videoRoute) = sink.GetRouteToSource(source, outputPort.Type, inputPort, outputPort); if (audioOrSingleRoute == null && videoRoute == null) { continue; } + Debug.LogVerbose("AudioOrSingleRoute Found: {audioRoute}", audioOrSingleRoute); + + Debug.LogVerbose("VideoRoute Found: {videoRoute}", videoRoute); + if (audioOrSingleRoute != null) { // Only add routes that have actual switching steps @@ -646,6 +650,8 @@ namespace PepperDash.Essentials.Core // Only the ones that are routing devices var midpointTieLines = destinationTieLines.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs); + Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count(), destination.Key); + //Create a list for tracking already checked devices to avoid loops, if it doesn't already exist from previous iteration if (alreadyCheckedDevices == null) alreadyCheckedDevices = new List(); @@ -685,7 +691,7 @@ namespace PepperDash.Essentials.Core if (goodInputPort == null) { - Debug.LogVerbose(destination, "No route found to {0}", source.Key); + Debug.LogVerbose(destination, "No route found to {0} from destination {1} for type {2}", source.Key, destination.Key, signalType); // Cache this as an impossible route _impossibleRoutes.TryAdd(routeKey, 0); diff --git a/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs b/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs index e9f7908c..bdefa09d 100644 --- a/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs +++ b/src/PepperDash.Essentials.Devices.Common/VideoCodec/MockVC/MockVC.cs @@ -21,7 +21,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec /// /// Represents a MockVC /// - public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets + public class MockVC : VideoCodecBase, IRoutingSource, IHasCallHistory, IHasScheduleAwareness, IHasCallFavorites, IHasDirectory, IHasCodecCameras, IHasCameraAutoMode, IHasCodecRoomPresets, IRoutingInputs { /// /// Gets or sets the PropertiesConfig diff --git a/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs b/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs index b0b5ef55..04c7e64d 100644 --- a/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs +++ b/src/PepperDash.Essentials.Devices.Common/VideoCodec/VideoCodecBase.cs @@ -26,7 +26,7 @@ namespace PepperDash.Essentials.Devices.Common.VideoCodec /// /// Base class for video codec devices /// - public abstract class VideoCodecBase : ReconfigurableDevice, IRoutingInputsOutputs, + public abstract class VideoCodecBase : ReconfigurableDevice, IUsageTracking, IHasDialer, IHasContentSharing, ICodecAudio, iVideoCodecInfo, IBridgeAdvanced, IHasStandbyMode { private const int XSigEncoding = 28591; From 92d13f29a9dfac9e9a919f152fc6ce6cd3b47584 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 21 May 2026 16:43:00 -0600 Subject: [PATCH 33/84] fix: refine routing logic by filtering IRoutingInputs and IRoutingOutputs, enhance logging for route mapping --- .../Routing/Extensions.cs | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index be533b70..78d0732e 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -374,12 +374,14 @@ namespace PepperDash.Essentials.Core IndexTieLines(); } - var sinks = DeviceManager.AllDevices.OfType(); - var sources = DeviceManager.AllDevices.OfType(); + var sinks = DeviceManager.AllDevices.OfType() + .Where(d => !(d is IRoutingInputsOutputs)).ToList(); + var sources = DeviceManager.AllDevices.OfType() + .Where(d => !(d is IRoutingInputsOutputs)).ToList(); - foreach (var sink in sinks.Where(d => !(d is IRoutingInputsOutputs))) + foreach (var sink in sinks) { - foreach (var source in sources.Where(d => !(d is IRoutingInputsOutputs))) + foreach (var source in sources) { foreach (var inputPort in sink.InputPorts) { @@ -392,10 +394,6 @@ namespace PepperDash.Essentials.Core continue; } - Debug.LogVerbose("AudioOrSingleRoute Found: {audioRoute}", audioOrSingleRoute); - - Debug.LogVerbose("VideoRoute Found: {videoRoute}", videoRoute); - if (audioOrSingleRoute != null) { // Only add routes that have actual switching steps @@ -404,6 +402,10 @@ namespace PepperDash.Essentials.Core continue; } + Debug.LogVerbose("Route mapped: {source} -> {sink} via {input}/{output}, type {type}", + source.Key, sink.Key, + inputPort.Key, outputPort.Key, audioOrSingleRoute.SignalType); + // Add to the appropriate collection(s) based on signal type // Note: A single route descriptor with combined flags (e.g., AudioVideo) will be added once per matching signal type if (audioOrSingleRoute.SignalType.HasFlag(eRoutingSignalType.Audio)) @@ -435,6 +437,10 @@ namespace PepperDash.Essentials.Core continue; } + Debug.LogVerbose("Video route mapped: {source} -> {sink} via {input}/{output}", + source.Key, sink.Key, + inputPort.Key, outputPort.Key); + RouteDescriptors[eRoutingSignalType.Video].AddRouteDescriptor(videoRoute); } } @@ -648,10 +654,12 @@ namespace PepperDash.Essentials.Core // No direct tie? Run back out on the inputs' attached devices... // Only the ones that are routing devices - var midpointTieLines = destinationTieLines.Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs); - - Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count(), destination.Key); + var midpointTieLines = destinationTieLines + .Where(t => t.SourcePort.ParentDevice is IRoutingInputsOutputs) + .ToList(); + Debug.LogVerbose(destination, "Found {tieLineCount} tie lines to walk for {destinationKey}", midpointTieLines.Count, destination.Key); + //Create a list for tracking already checked devices to avoid loops, if it doesn't already exist from previous iteration if (alreadyCheckedDevices == null) alreadyCheckedDevices = new List(); From 74a12c9671e4dac87b4eecef6ce8abb7e12f5185 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 21 May 2026 18:47:25 -0600 Subject: [PATCH 34/84] fix: add null or empty checks for midpoint keys in RoutingFeedbackManager --- .../Routing/RoutingFeedbackManager.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs index 16822ff2..7fe41c5c 100644 --- a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs +++ b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs @@ -66,6 +66,9 @@ namespace PepperDash.Essentials.Core.Routing foreach (var midpointKey in upstreamMidpoints) { + if (string.IsNullOrEmpty(midpointKey)) + continue; + if (!midpointToSinksMap.ContainsKey(midpointKey)) midpointToSinksMap[midpointKey] = new HashSet(); @@ -115,7 +118,8 @@ namespace PepperDash.Essentials.Core.Routing if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint) { - midpoints.Add(midpoint.Key); + if (!string.IsNullOrEmpty(midpoint.Key)) + midpoints.Add(midpoint.Key); // Find upstream TieLines connected to this midpoint's inputs var midpointInputs = (midpoint as IRoutingInputs)?.InputPorts; @@ -244,6 +248,9 @@ namespace PepperDash.Essentials.Core.Routing var upstreamMidpoints = GetUpstreamMidpoints(sink); foreach (var midpointKey in upstreamMidpoints) { + if (string.IsNullOrEmpty(midpointKey)) + continue; + if (!midpointToSinksMap.ContainsKey(midpointKey)) midpointToSinksMap[midpointKey] = new HashSet(); From abef1d095f624b8bb6aa526e755e0d7e86a7170e Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 22 May 2026 09:48:12 -0600 Subject: [PATCH 35/84] fix: prevent processing of null or empty midpoint keys in RoutingFeedbackManager --- .../Routing/RoutingFeedbackManager.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs index 16822ff2..7fe41c5c 100644 --- a/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs +++ b/src/PepperDash.Essentials.Core/Routing/RoutingFeedbackManager.cs @@ -66,6 +66,9 @@ namespace PepperDash.Essentials.Core.Routing foreach (var midpointKey in upstreamMidpoints) { + if (string.IsNullOrEmpty(midpointKey)) + continue; + if (!midpointToSinksMap.ContainsKey(midpointKey)) midpointToSinksMap[midpointKey] = new HashSet(); @@ -115,7 +118,8 @@ namespace PepperDash.Essentials.Core.Routing if (tieLine.SourcePort.ParentDevice is IRoutingWithFeedback midpoint) { - midpoints.Add(midpoint.Key); + if (!string.IsNullOrEmpty(midpoint.Key)) + midpoints.Add(midpoint.Key); // Find upstream TieLines connected to this midpoint's inputs var midpointInputs = (midpoint as IRoutingInputs)?.InputPorts; @@ -244,6 +248,9 @@ namespace PepperDash.Essentials.Core.Routing var upstreamMidpoints = GetUpstreamMidpoints(sink); foreach (var midpointKey in upstreamMidpoints) { + if (string.IsNullOrEmpty(midpointKey)) + continue; + if (!midpointToSinksMap.ContainsKey(midpointKey)) midpointToSinksMap[midpointKey] = new HashSet(); From 77c700c5659740a3f8c0f0d2b13ea6c673919da2 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 2 Jun 2026 11:25:48 -0600 Subject: [PATCH 36/84] fix: add escape handling for byte arrays and strings in DeviceJsonApi --- .../Devices/DeviceJsonApi.cs | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs b/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs index c4ec2ca9..c01d5e81 100644 --- a/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs +++ b/src/PepperDash.Essentials.Core/Devices/DeviceJsonApi.cs @@ -7,6 +7,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace PepperDash.Essentials.Core @@ -176,7 +177,20 @@ namespace PepperDash.Essentials.Core { if (!conversionType.IsEnum) { - return Convert.ChangeType(value, conversionType, System.Globalization.CultureInfo.InvariantCulture); + if (conversionType == typeof(byte[]) && value is string byteString) + { + var unescaped = UnescapeString(byteString); + return System.Text.Encoding.GetEncoding(28591).GetBytes(unescaped); + } + + var converted = Convert.ChangeType(value, conversionType, System.Globalization.CultureInfo.InvariantCulture); + + if (conversionType == typeof(string) && converted is string s) + { + return UnescapeString(s); + } + + return converted; } var stringValue = Convert.ToString(value); @@ -189,6 +203,32 @@ namespace PepperDash.Essentials.Core return Enum.Parse(conversionType, stringValue, true); } + /// + /// Processes escape sequences in a string, converting sequences like \r, \n, \t, \xHH + /// to their corresponding non-printable ASCII characters. + /// + private static string UnescapeString(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + return Regex.Replace(input, @"\\(r|n|t|\\|x[0-9A-Fa-f]{2})", match => + { + var seq = match.Groups[1].Value; + switch (seq) + { + case "r": return "\r"; + case "n": return "\n"; + case "t": return "\t"; + case "\\": return "\\"; + default: + // \xHH hex escape + var hex = seq.Substring(1); + return ((char)Convert.ToInt32(hex, 16)).ToString(); + } + }); + } + /// /// Gets the properties on a device /// From 1ce86dab291814938b18d524fd41e6c47b28e4b0 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 2 Jun 2026 14:10:33 -0600 Subject: [PATCH 37/84] fix: add escape handling for port forwarding in DebugSessionRequestHandler --- .../DebugSessionRequestHandler.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index 01e38834..56c983ae 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -57,6 +57,36 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers // Start the WS Server Debug.WebsocketSink.StartServerAndSetPort(port); Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose); + + // Attempt to forward the port to the CS LAN + try + { + var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType( + EthernetAdapterType.EthernetCSAdapter); + var csIp = CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); + + 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); + } + } + 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; @@ -90,8 +120,40 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// protected override void HandlePost(HttpCwsContext context) { + var port = Debug.WebsocketSink.Port; + Debug.WebsocketSink.StopServer(); + // Remove the port forwarding entry + try + { + var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType( + EthernetAdapterType.EthernetCSAdapter); + var csIp = CrestronEthernetHelper.GetEthernetParameter( + CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); + + var result = CrestronEthernetHelper.RemovePortForwarding( + (ushort)port, (ushort)port, csIp, + CrestronEthernetHelper.ePortMapTransport.TCP); + + if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr) + { + Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding for debug websocket: {0}", result); + } + else + { + Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed", port); + } + } + catch (ArgumentException) + { + Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding removal"); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Warning, "Error removing debug websocket port forwarding: {0}", ex.Message); + } + context.Response.StatusCode = 200; context.Response.StatusDescription = "OK"; context.Response.End(); From b32bab0d335b75a56de154c4352086e1f20db7c3 Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Tue, 9 Jun 2026 09:32:59 -0500 Subject: [PATCH 38/84] fix: remove impossibleRoutes cache The cache wasn't being cleared correctly, and was an unnecessary add. --- .../Routing/Extensions.cs | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 78d0732e..5063f8b3 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -52,13 +51,6 @@ namespace PepperDash.Essentials.Core /// private static Dictionary> _tieLinesBySource; - /// - /// Cache of failed route attempts to avoid re-checking impossible paths. - /// Format: "sourceKey|destKey|signalType" - /// Uses ConcurrentDictionary as a thread-safe set (byte value is unused). - /// - private static readonly ConcurrentDictionary _impossibleRoutes = new ConcurrentDictionary(); - /// /// Indexes all TieLines by source and destination device keys for faster lookups. /// Should be called once at system startup after all TieLines are created. @@ -121,29 +113,6 @@ namespace PepperDash.Essentials.Core return TieLineCollection.Default.Where(t => t.SourcePort.ParentDevice.Key == sourceKey); } - /// - /// Creates a cache key for route impossibility tracking. - /// - /// Source device key - /// Destination device key - /// Source port key - /// Destination port key - /// Signal type - /// Cache key string - private static string GetRouteKey(string sourceKey, string destKey, string sourcePortKey, string destinationPortKey, eRoutingSignalType type) - { - return $"{sourceKey}|{destKey}|{sourcePortKey}|{destinationPortKey}|{type}"; - } - - /// - /// Clears the impossible routes cache. Should be called if TieLines are added/removed at runtime. - /// - public static void ClearImpossibleRoutesCache() - { - _impossibleRoutes.Clear(); - Debug.LogInformation("Impossible routes cache cleared"); - } - /// /// Gets any existing RouteDescriptor for a destination, clears it using ReleaseRoute /// and then attempts a new Route and if sucessful, stores that RouteDescriptor @@ -594,14 +563,6 @@ namespace PepperDash.Essentials.Core { cycle++; - // Check if this route has already been determined to be impossible - var routeKey = GetRouteKey(source.Key, destination.Key, sourcePort?.Key ?? "auto", destinationPort?.Key ?? "auto", signalType); - if (_impossibleRoutes.ContainsKey(routeKey)) - { - Debug.LogVerbose("Route {0} is cached as impossible, skipping", routeKey); - return false; - } - Debug.LogVerbose("GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString()); RoutingInputPort goodInputPort = null; @@ -701,9 +662,6 @@ namespace PepperDash.Essentials.Core { Debug.LogVerbose(destination, "No route found to {0} from destination {1} for type {2}", source.Key, destination.Key, signalType); - // Cache this as an impossible route - _impossibleRoutes.TryAdd(routeKey, 0); - return false; } From c3511cd1a6c8e50fa6df2d49cd0d4dec2a03299d Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Tue, 9 Jun 2026 09:32:59 -0500 Subject: [PATCH 39/84] fix: remove impossibleRoutes cache The cache wasn't being cleared correctly, and was an unnecessary add. --- .../Routing/Extensions.cs | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index be533b70..9b9c458e 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -52,13 +51,6 @@ namespace PepperDash.Essentials.Core /// private static Dictionary> _tieLinesBySource; - /// - /// Cache of failed route attempts to avoid re-checking impossible paths. - /// Format: "sourceKey|destKey|signalType" - /// Uses ConcurrentDictionary as a thread-safe set (byte value is unused). - /// - private static readonly ConcurrentDictionary _impossibleRoutes = new ConcurrentDictionary(); - /// /// Indexes all TieLines by source and destination device keys for faster lookups. /// Should be called once at system startup after all TieLines are created. @@ -121,29 +113,6 @@ namespace PepperDash.Essentials.Core return TieLineCollection.Default.Where(t => t.SourcePort.ParentDevice.Key == sourceKey); } - /// - /// Creates a cache key for route impossibility tracking. - /// - /// Source device key - /// Destination device key - /// Source port key - /// Destination port key - /// Signal type - /// Cache key string - private static string GetRouteKey(string sourceKey, string destKey, string sourcePortKey, string destinationPortKey, eRoutingSignalType type) - { - return $"{sourceKey}|{destKey}|{sourcePortKey}|{destinationPortKey}|{type}"; - } - - /// - /// Clears the impossible routes cache. Should be called if TieLines are added/removed at runtime. - /// - public static void ClearImpossibleRoutesCache() - { - _impossibleRoutes.Clear(); - Debug.LogInformation("Impossible routes cache cleared"); - } - /// /// Gets any existing RouteDescriptor for a destination, clears it using ReleaseRoute /// and then attempts a new Route and if sucessful, stores that RouteDescriptor @@ -588,14 +557,6 @@ namespace PepperDash.Essentials.Core { cycle++; - // Check if this route has already been determined to be impossible - var routeKey = GetRouteKey(source.Key, destination.Key, sourcePort?.Key ?? "auto", destinationPort?.Key ?? "auto", signalType); - if (_impossibleRoutes.ContainsKey(routeKey)) - { - Debug.LogVerbose("Route {0} is cached as impossible, skipping", routeKey); - return false; - } - Debug.LogVerbose("GetRouteToSource: {cycle} {sourceKey}:{sourcePortKey}--> {destinationKey}:{destinationPortKey} {type}", null, cycle, source.Key, sourcePort?.Key ?? "auto", destination.Key, destinationPort?.Key ?? "auto", signalType.ToString()); RoutingInputPort goodInputPort = null; @@ -693,9 +654,6 @@ namespace PepperDash.Essentials.Core { Debug.LogVerbose(destination, "No route found to {0} from destination {1} for type {2}", source.Key, destination.Key, signalType); - // Cache this as an impossible route - _impossibleRoutes.TryAdd(routeKey, 0); - return false; } From 162a06f9e90aedcb6bdda10a38b81b48ccb3009c Mon Sep 17 00:00:00 2001 From: aknous Date: Wed, 10 Jun 2026 21:57:55 -0400 Subject: [PATCH 40/84] feat: adds interface for wireless sharing --- .../IHasWirelessSharing.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs new file mode 100644 index 00000000..6cd3835e --- /dev/null +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs @@ -0,0 +1,48 @@ +using System; + +namespace PepperDash.Essentials.Core.DeviceTypeInterfaces +{ + /// + /// Defines the contract for a wireless presentation endpoint that reports whether a wireless + /// sharing session is currently active. Implemented by platforms such as Crestron AirMedia, + /// Mersive Solstice, Barco ClickShare, Miracast/Teams receivers, etc. Allows consumers (e.g. + /// room plugins) to react to wireless sharing activity without taking a dependency on any + /// concrete device implementation. + /// + /// + /// This refers specifically to wireless screen/device mirroring, as distinct from in-call + /// content sharing on a video conference. + /// + public interface IHasWirelessSharing + { + /// + /// Reports whether a wireless sharing session is currently active (content is being presented). + /// + BoolFeedback IsSharingFeedback { get; } + + /// + /// Raised when wireless sharing starts or stops. The event args carry the new sharing state. + /// + event EventHandler SharingChanged; + } + + /// + /// Event arguments describing a change in wireless sharing state. + /// + public class WirelessSharingEventArgs : EventArgs + { + /// + /// True if a wireless sharing session is active (content is being presented), false otherwise. + /// + public bool IsSharing { get; private set; } + + /// + /// Creates a new . + /// + /// True if a wireless sharing session is active, false otherwise. + public WirelessSharingEventArgs(bool isSharing) + { + IsSharing = isSharing; + } + } +} From d18fca8a9889a99d5d59417c4e33d48b4b559865 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Wed, 10 Jun 2026 13:34:05 -0600 Subject: [PATCH 41/84] fix: handle null properties in DeviceMessageBase and DeviceStateMessageBase --- .../Messengers/DeviceMessageBase.cs | 8 ++++---- .../Messengers/DeviceStateMessageBase.cs | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs index 54a6ec36..0198df2f 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceMessageBase.cs @@ -10,7 +10,7 @@ namespace PepperDash.Essentials.AppServer.Messengers /// /// The device key /// - [JsonProperty("key")] + [JsonProperty("key", NullValueHandling = NullValueHandling.Ignore)] /// /// Gets or sets the Key /// @@ -19,19 +19,19 @@ namespace PepperDash.Essentials.AppServer.Messengers /// /// The device name /// - [JsonProperty("name")] + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } /// /// The type of the message class /// - [JsonProperty("messageType")] + [JsonProperty("messageType", NullValueHandling = NullValueHandling.Ignore)] public string MessageType => GetType().Name; /// /// Gets or sets the MessageBasePath /// - [JsonProperty("messageBasePath")] + [JsonProperty("messageBasePath", NullValueHandling = NullValueHandling.Ignore)] public string MessageBasePath { get; set; } } diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs index a5df51a8..4241b69c 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs @@ -12,7 +12,8 @@ namespace PepperDash.Essentials.AppServer.Messengers /// /// The interfaces implmented by the device sending the messsage /// - [JsonProperty("interfaces")] + [JsonProperty("interfaces", NullValueHandling = NullValueHandling.Ignore)] + [Obsolete("Interfaces 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 List Interfaces { get; private set; } /// From 5f26cb98fd4def73cafba659d908f60c4bc729cb Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 11:28:39 -0600 Subject: [PATCH 42/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DebugSessionRequestHandler.cs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index 56c983ae..1eeb2780 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -132,17 +132,24 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var csIp = CrestronEthernetHelper.GetEthernetParameter( CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); - var result = CrestronEthernetHelper.RemovePortForwarding( - (ushort)port, (ushort)port, csIp, - CrestronEthernetHelper.ePortMapTransport.TCP); - - if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr) + if (port <= 0) { - Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding for debug websocket: {0}", result); + Debug.LogMessage(LogEventLevel.Debug, "Debug websocket port is not set; skipping port forwarding removal"); } else { - Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed", port); + 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) From 907eb2f3972831c66e0250d87d1761b8901225a6 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 14:29:49 -0600 Subject: [PATCH 43/84] fix: add csIp handling and update debug session URL in DebugSessionRequestHandler --- .../Web/RequestHandlers/DebugSessionRequestHandler.cs | 6 ++++-- .../Messengers/MessengerBase.cs | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index 1eeb2780..e6044144 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -48,6 +48,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0); var port = 0; + string csIp = null; if (!Debug.WebsocketSink.IsRunning) { @@ -63,7 +64,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers { var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType( EthernetAdapterType.EthernetCSAdapter); - var csIp = CrestronEthernetHelper.GetEthernetParameter( + csIp = CrestronEthernetHelper.GetEthernetParameter( CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); var result = CrestronEthernetHelper.AddPortForwarding( @@ -93,7 +94,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers object data = new { - url = Debug.WebsocketSink.Url + url = Debug.WebsocketSink.Url, + csLanUrl = csIp != null ? url.Replace(ip, csIp) : null }; Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url); diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs index 3031f4ba..eb3afec3 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs @@ -264,6 +264,9 @@ namespace PepperDash.Essentials.AppServer.Messengers message.Name = _device.Name; + message.MessageBasePath = MessagePath; + + var token = JToken.FromObject(message); PostStatusMessage(token, MessagePath, clientId); From 782bb6c057c0b9c901313a47f50313e5c023c2eb Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 14:52:35 -0600 Subject: [PATCH 44/84] fix: improve CS LAN IP handling and update fallback debug session URL in DebugSessionRequestHandler --- .../DebugSessionRequestHandler.cs | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index e6044144..d1d27194 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -58,15 +58,18 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers // Start the WS Server Debug.WebsocketSink.StartServerAndSetPort(port); Debug.SetWebSocketMinimumDebugLevel(Serilog.Events.LogEventLevel.Verbose); + } - // Attempt to forward the port to the CS LAN - try + // 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 csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType( - EthernetAdapterType.EthernetCSAdapter); - csIp = CrestronEthernetHelper.GetEthernetParameter( - CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId); - var result = CrestronEthernetHelper.AddPortForwarding( (ushort)port, (ushort)port, csIp, CrestronEthernetHelper.ePortMapTransport.TCP); @@ -80,25 +83,26 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port); } } - 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); - } + } + catch (ArgumentException) + { + Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding"); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Warning, "Error automatically forwarding debug websocket port to CS LAN: {0}", ex.Message); } var url = Debug.WebsocketSink.Url; - object data = new + var data = new { url = Debug.WebsocketSink.Url, - csLanUrl = csIp != null ? url.Replace(ip, csIp) : null + fallbackUrl = csIp != null ? url.Replace(csIp, ip) : null }; Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url); + Debug.LogMessage(LogEventLevel.Information, "Fallback Debug Session URL: {0}", data.fallbackUrl); // Return the port number with the full url of the WS Server var res = JsonConvert.SerializeObject(data); From 39744553375044e0daee30168c5cc75d3d502214 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 12 Jun 2026 15:08:34 -0600 Subject: [PATCH 45/84] fix: add port forward timeout handling in DebugSessionRequestHandler --- .../Logging/DebugWebsocketSink.cs | 16 +++++ .../DebugSessionRequestHandler.cs | 58 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index eeba5772..cfbf5785 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -72,6 +72,20 @@ namespace PepperDash.Core /// public bool IsRunning { get => _httpsServer?.IsListening ?? false; } + /// + /// Gets a value indicating whether there are active WebSocket connections. + /// + public bool HasActiveConnections + { + get + { + if (_httpsServer == null || !_httpsServer.IsListening) return false; + var service = _httpsServer.WebSocketServices[_path]; + if (service == null) return false; + return service.Sessions.Count > 0; + } + } + private readonly ITextFormatter _textFormatter; @@ -217,6 +231,8 @@ namespace PepperDash.Core { Debug.LogInformation("Starting Websocket Server on port: {0}", port); + + Start(port, CertPath, _certificatePassword); } diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs index d1d27194..59c662dc 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs @@ -17,7 +17,10 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// Represents a DebugSessionRequestHandler /// public class DebugSessionRequestHandler : WebApiBaseRequestHandler - { + { + private CTimer _portForwardTimeoutTimer; + private readonly object _timerLock = new object(); + /// /// Constructor /// @@ -81,6 +84,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers else { Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port); + StartPortForwardTimeout(port, csIp); } } } @@ -126,6 +130,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// protected override void HandlePost(HttpCwsContext context) { + CancelPortForwardTimeout(); + var port = Debug.WebsocketSink.Port; Debug.WebsocketSink.StopServer(); @@ -174,5 +180,55 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped"); } + private void StartPortForwardTimeout(int port, string csIp) + { + lock (_timerLock) + { + _portForwardTimeoutTimer?.Dispose(); + _portForwardTimeoutTimer = new CTimer(_ => + { + if (Debug.WebsocketSink.HasActiveConnections) + { + Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward"); + return; + } + + Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port); + + try + { + var result = CrestronEthernetHelper.RemovePortForwarding( + (ushort)port, (ushort)port, csIp, + CrestronEthernetHelper.ePortMapTransport.TCP); + + if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr) + { + Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", result); + } + else + { + Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed due to timeout", port); + } + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", ex.Message); + } + }, 30000); + } + } + + /// + /// Cancels the port forward timeout timer if a session is being explicitly stopped. + /// + private void CancelPortForwardTimeout() + { + lock (_timerLock) + { + _portForwardTimeoutTimer?.Dispose(); + _portForwardTimeoutTimer = null; + } + } + } } From a7b839296e5de20293c842ea08d52a36674315db Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:05:14 -0500 Subject: [PATCH 46/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index aca38333..077e2038 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -115,7 +115,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces PoeEnableInProgress, /// - /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState + /// Indicates that PoE has been enabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling. /// PoEEnabled } From e03e45ccf5c0fd3c4510766fb14cf5691808c929 Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:05:25 -0500 Subject: [PATCH 47/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 077e2038..5e1e4f1a 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -80,7 +80,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces } /// - /// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes. + /// Enumeration of network switch port state change event types (e.g. VLAN changes or PoE state changes). /// public enum NetworkSwitchPortEventType { From b982219e2f0ef1bfab98a42fa6f934acfd9c247a Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:05:36 -0500 Subject: [PATCH 48/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 5e1e4f1a..37fb024d 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -95,7 +95,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces VlanChangeInProgress, /// - /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan + /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan or an external change detected by polling. /// VlanChanged, From a22047410158b065a42eaf858b9345ade92179ee Mon Sep 17 00:00:00 2001 From: equinoy <153123103+equinoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:05:50 -0500 Subject: [PATCH 49/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DeviceTypeInterfaces/INetworkSwitchControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs index 37fb024d..6c6e5598 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -105,7 +105,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces PoeDisableInProgress, /// - /// Indicates that the PoE state on a port has changed, either through a successful call to SetPortPoeState + /// Indicates that PoE has been disabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling. /// PoEDisabled, From 8d3edde28ca6a50e7dc0551af2c41a5a72c9d11b Mon Sep 17 00:00:00 2001 From: aknous Date: Wed, 17 Jun 2026 10:06:00 -0400 Subject: [PATCH 50/84] feat: adds config props to disable power on/off automation --- .../Displays/ScreenLiftController.cs | 18 ++++++++++++++++++ .../ScreenLiftControllerConfigProperties.cs | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs index 80c1f8ee..d542f52a 100644 --- a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs +++ b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs @@ -152,6 +152,15 @@ namespace PepperDash.Essentials.Devices.Common.Shades private void IsCoolingDownFeedback_OutputChange(object sender, FeedbackEventArgs e) { + if (Config.DisableAutoRaiseOnPowerOff) + { + this.LogDebug( + "Auto-raise on power-off disabled for {type}; leaving position unchanged (manual control only)", + Type + ); + return; + } + if ( !DisplayDevice.IsCoolingDownFeedback.BoolValue && Type == eScreenLiftControlType.lift @@ -174,6 +183,15 @@ namespace PepperDash.Essentials.Devices.Common.Shades { if (DisplayDevice.IsWarmingUpFeedback.BoolValue) { + if (Config.DisableAutoLowerOnPowerOn) + { + this.LogDebug( + "Auto-lower on power-on disabled for {type}; leaving position unchanged (manual control only)", + Type + ); + return; + } + Lower(); } } diff --git a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs index 1c4f9906..88113a08 100644 --- a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs +++ b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs @@ -41,5 +41,23 @@ namespace PepperDash.Essentials.Devices.Common.Shades /// [JsonProperty("muteOnScreenUp")] public bool MuteOnScreenUp { get; set; } + + /// + /// When true, this controller does NOT automatically lower when its assigned display powers on + /// (warms up). Manual Raise/Lower still work, and the power-off auto-raise is unaffected. Intended + /// for a projector screen that must not auto-drop in a public space for safety, while the projector + /// lift (a separate controller) can still drop automatically. + /// + [JsonProperty("disableAutoLowerOnPowerOn")] + public bool DisableAutoLowerOnPowerOn { get; set; } + + /// + /// When true, this controller does NOT automatically raise when its assigned display powers off + /// (cools down). Manual Raise/Lower still work, and the power-on auto-lower is unaffected. The + /// companion to ; together they make a controller fully + /// manual while leaving other controllers (e.g. the lift) on their default automatic behavior. + /// + [JsonProperty("disableAutoRaiseOnPowerOff")] + public bool DisableAutoRaiseOnPowerOff { get; set; } } } From 3286d27898ef0378e27d4e7cef862bb0544489eb Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:09:32 +0000 Subject: [PATCH 51/84] Initial plan From af5611e403dc7fa8651b595bc493550db7be6361 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:11:48 +0000 Subject: [PATCH 52/84] Mark IMobileControlMessengerWithSubscriptions and EnableMessengerSubscriptions as obsolete All messengers are now subscription based in v3.x, making these constructs no longer necessary. Closes #1435 Agent-Logs-Url: https://github.com/PepperDash/Essentials/sessions/bda64c9c-5343-412b-801f-5e60816bc38d Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com> --- .../IMobileControlMessengerWithSubscriptions.cs | 2 ++ .../MobileControlConfig.cs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs index 887f1789..e6365571 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs @@ -1,3 +1,4 @@ +using System; using PepperDash.Core; namespace PepperDash.Essentials.Core.DeviceTypeInterfaces @@ -5,6 +6,7 @@ namespace PepperDash.Essentials.Core.DeviceTypeInterfaces /// /// Defines the contract for IMobileControlMessenger /// + [Obsolete("This interface is obsolete and will be removed in a future version. All messengers are now subscription based.")] public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger { /// diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs index ec7219a3..9beed963 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -43,6 +44,7 @@ namespace PepperDash.Essentials /// Enable subscriptions for Messengers /// [JsonProperty("enableMessengerSubscriptions")] + [Obsolete("This property is obsolete and will be removed in a future version. All messengers are now subscription based.")] public bool EnableMessengerSubscriptions { get; set; } } From 2fac0ca926b752d96b8bb777e923a5830ffc5baa Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 26 Jun 2026 14:32:22 -0600 Subject: [PATCH 53/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../IMobileControlMessengerWithSubscriptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs index e6365571..8603d5b6 100644 --- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs @@ -4,7 +4,7 @@ using PepperDash.Core; namespace PepperDash.Essentials.Core.DeviceTypeInterfaces { /// - /// Defines the contract for IMobileControlMessenger + /// Obsolete: messengers are subscription based by default; use IMobileControlMessenger instead. /// [Obsolete("This interface is obsolete and will be removed in a future version. All messengers are now subscription based.")] public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger From 640bd7a8a781a33f734a5eb03086a79550ac344a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:55:49 +0000 Subject: [PATCH 54/84] Update XML summary for EnableMessengerSubscriptions to reflect obsolete status --- src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs index 9beed963..27c9d31e 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs @@ -41,7 +41,7 @@ namespace PepperDash.Essentials public bool EnableApiServer { get; set; } = true; /// - /// Enable subscriptions for Messengers + /// Retained for backward compatibility only. This property is obsolete; all messengers are now subscription based. /// [JsonProperty("enableMessengerSubscriptions")] [Obsolete("This property is obsolete and will be removed in a future version. All messengers are now subscription based.")] From 0240887d93e890a56c42ce8c8e78f794d5406215 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Fri, 26 Jun 2026 15:57:52 -0600 Subject: [PATCH 55/84] Clarify summary for EnableMessengerSubscriptions property Updated the summary comment for EnableMessengerSubscriptions property to clarify its purpose. --- .../MobileControlConfig.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs index 27c9d31e..963e7fd5 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs @@ -41,7 +41,7 @@ namespace PepperDash.Essentials public bool EnableApiServer { get; set; } = true; /// - /// Retained for backward compatibility only. This property is obsolete; all messengers are now subscription based. + /// Enables subscriptions for messengers /// [JsonProperty("enableMessengerSubscriptions")] [Obsolete("This property is obsolete and will be removed in a future version. All messengers are now subscription based.")] @@ -290,4 +290,4 @@ namespace PepperDash.Essentials /// NEO } -} \ No newline at end of file +} From 8ac4eb75843f1267f410443e15c1a267792213a2 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:32:56 +0000 Subject: [PATCH 56/84] refactor: marked mobile control subscription items as obsolete Co-authored-by: ndorin <18535240+ndorin@users.noreply.github.com> From 68c44e46aef807c2544f711d95baa0a519e4264c Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Thu, 2 Jul 2026 15:06:51 -0500 Subject: [PATCH 57/84] fix: string formatting for console responses was incorrect in some cases and causing exceptions --- .../Secrets/SecretsManager.cs | 36 ++++++------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs index 382f5d55..ac4e80d8 100644 --- a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs +++ b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using Crestron.SimplSharp; using PepperDash.Core; +using PepperDash.Core.Logging; using Serilog.Events; namespace PepperDash.Essentials.Core @@ -296,19 +297,14 @@ namespace PepperDash.Essentials.Core { var secretPresent = provider.TestSecret(key); - Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); + provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); if (!secretPresent) return - String.Format( - "Unable to update secret for {0}:{1} - Please use the 'SetSecret' command to modify it"); + $"Unable to update secret for {provider.Key}:{key} - Please use the 'SetSecret' command to modify it"; var response = provider.SetSecret(key, secret) - ? String.Format( - "Secret successfully set for {0}:{1}", - provider.Key, key) - : String.Format( - "Unable to set secret for {0}:{1}", - provider.Key, key); + ? $"Secret successfully set for {provider.Key}:{key}" + : $"Unable to set secret for {provider.Key}:{key}"; return response; } @@ -316,19 +312,14 @@ namespace PepperDash.Essentials.Core { var secretPresent = provider.TestSecret(key); - Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); + provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key); if (secretPresent) return - String.Format( - "Unable to set secret for {0}:{1} - Please use the 'UpdateSecret' command to modify it"); + $"Unable to set secret for {provider.Key}:{key} - Please use the 'UpdateSecret' command to modify it"; var response = provider.SetSecret(key, secret) - ? String.Format( - "Secret successfully set for {0}:{1}", - provider.Key, key) - : String.Format( - "Unable to set secret for {0}:{1}", - provider.Key, key); + ? $"Secret successfully set for {provider.Key}:{key}" + : $"Unable to set secret for {provider.Key}:{key}"; return response; } @@ -377,15 +368,10 @@ namespace PepperDash.Essentials.Core var key = args[1]; - provider.SetSecret(key, ""); response = provider.SetSecret(key, "") - ? String.Format( - "Secret successfully deleted for {0}:{1}", - provider.Key, key) - : String.Format( - "Unable to delete secret for {0}:{1}", - provider.Key, key); + ? $"Secret successfully deleted for {provider.Key}:{key}" + : $"Unable to delete secret for {provider.Key}:{key}"; CrestronConsole.ConsoleCommandResponse(response); return; From 64d60dacc256bea48f8642c0f204486f2bd681f4 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Thu, 2 Jul 2026 17:13:26 -0500 Subject: [PATCH 58/84] feat: update version object Add touchpanelWrapperApp, userInterfaces, and repoUrl to VersionData config schema VersionData/NugetVersion previously only modeled the essentials and packages entries under the versions node. This adds support for the touchpanelWrapperApp (single object) and userInterfaces (array) nodes, plus a repoUrl property on NugetVersion so it round-trips instead of being silently dropped, matching the schema produced by the vsce-essentials-version-manager extension. --- src/Directory.Build.props | 4 ++-- .../Config/Essentials/EssentialsConfig.cs | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7435df6f..73c3a7bb 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,11 +1,11 @@ - 2.29.0-local + 2.36.6-local $(Version) PepperDash Technology PepperDash Technology PepperDash Essentials - Copyright © 2025 + Copyright © 2026 https://github.com/PepperDash/Essentials git Crestron; 4series diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs index 6ffe07d2..c25ab40f 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs @@ -134,12 +134,25 @@ namespace PepperDash.Essentials.Core.Config [JsonProperty("packages")] public List Packages { get; set; } + /// + /// Gets or sets the touchpanel wrapper app version + /// + [JsonProperty("touchpanelWrapperApp")] + public NugetVersion TouchpanelWrapperApp { get; set; } + + /// + /// Gets or sets the list of user interface packages + /// + [JsonProperty("userInterfaces")] + public List UserInterfaces { get; set; } + /// /// Initializes a new instance of the class. /// public VersionData() { Packages = new List(); + UserInterfaces = new List(); } } @@ -159,6 +172,12 @@ namespace PepperDash.Essentials.Core.Config /// [JsonProperty("packageId")] public string PackageId { get; set; } + + /// + /// Gets or sets the RepoUrl + /// + [JsonProperty("repoUrl")] + public string RepoUrl { get; set; } } /// From 588057f3bacc606bdd1295f6535bf731a4ecc2dd Mon Sep 17 00:00:00 2001 From: jkdevito Date: Sun, 5 Jul 2026 20:57:05 -0500 Subject: [PATCH 59/84] feat: add packageManifest CWS API route New GET https://{ip}/cws/app{xx}/api/packageManifest route returning a JSON package manifest (Essentials + plugins + user interfaces) shaped to hydrate the vsce-essentials-version-manager extension's VersionsSnapshot object. - EssentialsConfig.cs: add [JsonProperty("versions")] to EssentialsConfig.Versions (was attribute-less); add NugetVersion.Name; NullValueHandling.Ignore on PackageId/RepoUrl/Name. - New GetPackageManifestRequestHandler: deep-copies the config's VersionData (never mutates the live config object), then enriches it via reflection: - essentials: version from Global.AssemblyVersion, repoUrl/name from the RepositoryUrl AssemblyMetadata + AssemblyProduct of PepperDash.Essentials.Core's own assembly (PluginLoader.EssentialsAssembly.Assembly is null at runtime due to a pre-existing name-matching bug, so this route reads its own loaded assembly instead), packageId from config or a constant. - packages[]: merges PluginLoader.EssentialsPluginAssemblies (matched to config packages by packageId via AssemblyTitle -> AssemblyName -> AssemblyName minus a trailing .4Series suffix) with reflection supplying version and filling missing repoUrl/name; unmatched loaded assemblies are emitted without a packageId; configured-but-not-loaded packages pass through unchanged. - userInterfaces/touchpanelWrapperApp are passed through from config as-is. - Entries with no resolvable version are skipped (the extension's parser drops entries whose version isn't a string). - EssentialsWebApi.cs: register the new packageManifest route next to versions. Build verified clean (Core + Essentials program, 0 errors). Not yet tested on hardware. --- .../Config/Essentials/EssentialsConfig.cs | 11 +- .../Web/EssentialsWebApi.cs | 5 + .../GetPackageManifestRequestHandler.cs | 231 ++++++++++++++++++ 3 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs index c25ab40f..44275ef0 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs @@ -105,6 +105,7 @@ namespace PepperDash.Essentials.Core.Config /// /// Gets or sets the Versions /// + [JsonProperty("versions")] public VersionData Versions { get; set; } /// @@ -170,14 +171,20 @@ namespace PepperDash.Essentials.Core.Config /// /// Gets or sets the PackageId /// - [JsonProperty("packageId")] + [JsonProperty("packageId", NullValueHandling = NullValueHandling.Ignore)] public string PackageId { get; set; } /// /// Gets or sets the RepoUrl /// - [JsonProperty("repoUrl")] + [JsonProperty("repoUrl", NullValueHandling = NullValueHandling.Ignore)] public string RepoUrl { get; set; } + + /// + /// Gets or sets the human-readable name + /// + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get; set; } } /// diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index 3cdb8433..cfbaa1df 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -95,6 +95,11 @@ namespace PepperDash.Essentials.Core.Web Name = "ReportVersions", RouteHandler = new ReportVersionsRequestHandler() }, + new HttpCwsRoute("packageManifest") + { + Name = "GetPackageManifest", + RouteHandler = new GetPackageManifestRequestHandler() + }, new HttpCwsRoute("appdebug") { Name = "AppDebug", diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs new file mode 100644 index 00000000..9be5e78f --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -0,0 +1,231 @@ +using System; +using System.Linq; +using System.Reflection; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Config; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Represents a GetPackageManifestRequestHandler + /// + public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler + { + /// + /// Constructor + /// + /// + /// base(true) enables CORS support by default + /// + public GetPackageManifestRequestHandler() + : base(true) + { + } + + /// + /// Handles GET method requests + /// + /// + protected override void HandleGet(HttpCwsContext context) + { + try + { + var result = CloneVersionData(ConfigReader.ConfigObject?.Versions) ?? new VersionData(); + + PopulateEssentials(result); + PopulatePackages(result); + + var js = JsonConvert.SerializeObject(result, Formatting.Indented); + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(js, false); + context.Response.End(); + } + catch (Exception) + { + context.Response.StatusCode = 500; + context.Response.StatusDescription = "Internal Server Error"; + context.Response.End(); + } + } + + /// + /// Deep-copies the config's VersionData so the live config object is never mutated + /// + private static VersionData CloneVersionData(VersionData source) + { + if (source == null) + { + return null; + } + + var json = JsonConvert.SerializeObject(source); + return JsonConvert.DeserializeObject(json); + } + + /// + /// Enriches (or creates) the essentials entry from the loaded PepperDash.Essentials.Core assembly + /// + private static void PopulateEssentials(VersionData result) + { + var essentials = result.Essentials ?? new NugetVersion(); + + essentials.Version = Global.AssemblyVersion; + + // PepperDash_Essentials_Core.dll - same repo/Directory.Build.props as PepperDashEssentials.dll, + // and unlike PluginLoader.EssentialsAssembly, this Assembly reference is never null at runtime. + var essentialsAssembly = 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; + } + + if (string.IsNullOrEmpty(essentials.PackageId)) + { + essentials.PackageId = "PepperDash.Essentials"; + } + + result.Essentials = essentials; + } + + /// + /// Merges reflection data from loaded plugin assemblies with the config's packages list + /// + private static void PopulatePackages(VersionData result) + { + var configPackages = result.Packages ?? new System.Collections.Generic.List(); + var matchedConfigPackages = new System.Collections.Generic.HashSet(); + var mergedPackages = new System.Collections.Generic.List(); + + foreach (var loaded in PluginLoader.EssentialsPluginAssemblies.Where(a => a.Assembly != null)) + { + var reflectedVersion = loaded.Version; + if (string.IsNullOrEmpty(reflectedVersion)) + { + // Never emit an entry with no version - the extension's parser drops entries + // whose version isn't a string. + continue; + } + + var reflectedRepoUrl = TrimTrailingGit(GetAssemblyMetadataValue(loaded.Assembly, "RepositoryUrl")); + var reflectedName = GetAssemblyProduct(loaded.Assembly); + + 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, 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 + { + PackageId = match.PackageId, + Version = reflectedVersion, + RepoUrl = !string.IsNullOrEmpty(match.RepoUrl) ? match.RepoUrl : reflectedRepoUrl, + Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName + }); + } + else + { + // Loaded but not present (or not matched) in config - emit without a packageId + mergedPackages.Add(new NugetVersion + { + Version = reflectedVersion, + RepoUrl = reflectedRepoUrl, + Name = reflectedName + }); + } + } + + // Configured but not currently loaded - pass through unchanged + mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p))); + + result.Packages = mergedPackages; + } + + private static string GetAssemblyMetadataValue(Assembly assembly, string key) + { + if (assembly == null) + { + return null; + } + + var match = assembly.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false) + .Cast() + .FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase)); + + return match?.Value; + } + + private static string GetAssemblyProduct(Assembly assembly) + { + if (assembly == null) + { + return null; + } + + var attribute = assembly.GetCustomAttributes(typeof(AssemblyProductAttribute), false) + .FirstOrDefault() as AssemblyProductAttribute; + + return attribute?.Product; + } + + private static string GetAssemblyTitle(Assembly assembly) + { + if (assembly == null) + { + return null; + } + + var attribute = assembly.GetCustomAttributes(typeof(AssemblyTitleAttribute), false) + .FirstOrDefault() as AssemblyTitleAttribute; + + return attribute?.Title; + } + + private static string StripTrailingSeriesSuffix(string assemblyName) + { + const string suffix = ".4Series"; + + if (string.IsNullOrEmpty(assemblyName) || !assemblyName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return assemblyName; + } + + return assemblyName.Substring(0, assemblyName.Length - suffix.Length); + } + + private static string TrimTrailingGit(string repoUrl) + { + const string suffix = ".git"; + + if (string.IsNullOrEmpty(repoUrl) || !repoUrl.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return repoUrl; + } + + return repoUrl.Substring(0, repoUrl.Length - suffix.Length); + } + } +} From 2f7789b374bf91112665f3f97e1a4b07bcd1b5f0 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 16:22:23 -0500 Subject: [PATCH 60/84] fix: prefer embedded PackageId assembly metadata in packageManifest API WHAT / WHY ---------- GetPackageManifestRequestHandler previously identified a loaded plugin's NuGet PackageId by fuzzy-matching AssemblyTitle/AssemblyName against the config's packageId - a chain that silently fails for any plugin whose AssemblyTitle/AssemblyName doesn't happen to match its PackageId (verified against real shipped plugin DLLs; see FINDINGS-nuget-packageid-gaps.md). epi-symetrix-dsp and epi-shure-mxa have already backported a Directory.Build.props change that embeds ``, giving Essentials an unambiguous, authoritative PackageId via AssemblyMetadataAttribute("PackageId", ...) instead of guessing. Validated by building both plugins and inspecting the generated AssemblyInfo.cs. Changes: - src/Directory.Build.props: add the same AssemblyMetadata PackageId item, so every Essentials-owned assembly (Core, Essentials, Devices.Common, MobileControl, MobileControl.Messengers) now embeds its real PackageId too - previously none of them did. - GetPackageManifestRequestHandler.cs: - PopulatePackages: read AssemblyMetadataAttribute("PackageId", ...) from loaded plugin assemblies and use it as the first-priority match/identity signal, ahead of the AssemblyTitle -> AssemblyName -> AssemblyName-minus- ".4Series" fallback chain. Loaded-but-unconfigured plugins that carry this metadata now report a PackageId in the manifest instead of null. - PopulateEssentials: replace the hardcoded "PepperDash.Essentials" fallback (which matched none of the real PackageIds) with the reflected value from PepperDash.Essentials.Core's own assembly metadata. Fully backward compatible: plugins without the updated Directory.Build.props (most existing epi-* repos today) fall through to the prior fallback chain unchanged. RECOMMENDATIONS - Essentials & sub-projects (this repo) -------------------------------------------------------- - AssemblyName/AssemblyTitle drift from PackageId across sub-projects (confirmed via generated AssemblyInfo.cs, not assumed): PepperDash.Essentials.Core: PackageId "PepperDash.Essentials.Core" vs AssemblyName "PepperDash_Essentials_Core" PepperDash.Essentials.Devices.Common: PackageId "PepperDash.Essentials.Devices.Common" vs AssemblyName "Essentials Devices Common" PepperDash.Essentials.MobileControl: PackageId "PepperDash.Essentials.MobileControl" vs AssemblyName "epi-essentials-mobile-control" PepperDash.Essentials.MobileControl.Messengers: PackageId "...Messengers" vs AssemblyName "mobile-control-messengers" Only PepperDash.Essentials and PepperDash.Core happen to agree. Fixing AssemblyName changes the physical .dll filename for existing consumers, so this needs a deliberate, versioned decision - not bundled here. - Once this ships and bakes for a release or two, consider deleting the now-redundant "PepperDash.Essentials" hardcoded string entirely and the Product/AssemblyTitle-based Name fallback, since AssemblyMetadata PackageId supersedes both for any assembly built after this change. RECOMMENDATIONS - EPI plugin repos (epi-*) ------------------------------------------- - Backport `` into every existing epi-* repo's src/Directory.Build.props (recommendation E from FINDINGS-nuget-packageid-gaps.md). This is opt-in and additive - repos that skip it keep working via the existing fallback chain, but gain nothing until they backport it and cut a new release. - Land the corresponding fix in EssentialsPluginTemplate (src/Directory.Build.props + src/epi-make-model.4Series.csproj) so all *new* plugin repos get this by default, and fix the template's own AssemblyTitle/PackageId drift ("Plugin" vs "Plugins") while there. - Already-published plugin versions can't be retroactively fixed - this only takes effect on a plugin's next release after adopting the template change. WORKFLOW RECOMMENDATIONS ------------------------- - Extend workflow-templates' essentialsplugins-4Series-builds.yml "Check Package Name" step to validate the built DLL's embedded AssemblyMetadataAttribute("PackageId", ...) (and/or AssemblyTitle as a fallback) against the repo-derived expected package name - today it only compares the .nupkg filename, which would not have caught drift like epi-display-samsung-mdc's AssemblyTitle mismatch. - Sequence this after the template + per-repo backports have landed and baked for a release cycle, otherwise it will fail CI for every epi-* repo that hasn't picked up the Directory.Build.props change yet. Gate it behind the existing bypassPackageCheck input for repos not yet ready. --- src/Directory.Build.props | 3 ++ .../GetPackageManifestRequestHandler.cs | 32 ++++++++++++++----- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 73c3a7bb..ab9f2732 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -20,4 +20,7 @@ + + + diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index 9be5e78f..aa1663a4 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -93,7 +93,15 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers essentials.Name = name; } - if (string.IsNullOrEmpty(essentials.PackageId)) + // 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 = "PepperDash.Essentials"; } @@ -123,6 +131,11 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var reflectedRepoUrl = TrimTrailingGit(GetAssemblyMetadataValue(loaded.Assembly, "RepositoryUrl")); var reflectedName = GetAssemblyProduct(loaded.Assembly); + // Plugins built from a Directory.Build.props that embeds + // carry their PackageId + // directly - this is authoritative and should be preferred over the title/name fallback chain. + var reflectedPackageId = GetAssemblyMetadataValue(loaded.Assembly, "PackageId"); + var assemblyTitle = GetAssemblyTitle(loaded.Assembly); var assemblyName = loaded.Assembly.GetName().Name; var assemblyNameNoSeriesSuffix = StripTrailingSeriesSuffix(assemblyName); @@ -130,7 +143,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var match = configPackages.FirstOrDefault(p => !matchedConfigPackages.Contains(p) && !string.IsNullOrEmpty(p.PackageId) && - (string.Equals(p.PackageId, assemblyTitle, StringComparison.OrdinalIgnoreCase) || + (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))); @@ -140,20 +154,22 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers mergedPackages.Add(new NugetVersion { - PackageId = match.PackageId, - Version = reflectedVersion, + Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName, RepoUrl = !string.IsNullOrEmpty(match.RepoUrl) ? match.RepoUrl : reflectedRepoUrl, - Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName + PackageId = !string.IsNullOrEmpty(reflectedPackageId) ? reflectedPackageId : match.PackageId, + Version = reflectedVersion }); } else { - // Loaded but not present (or not matched) in config - emit without a packageId + // 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 { - Version = reflectedVersion, + Name = reflectedName, RepoUrl = reflectedRepoUrl, - Name = reflectedName + PackageId = reflectedPackageId, + Version = reflectedVersion, }); } } From 47e186d8f4ad4fd2a19aab460baab1c231be8f8d Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 16:55:43 -0500 Subject: [PATCH 61/84] fix(config): correctly detect v1 vs v2 config and preserve versions node - Determine config version by presence of "system" and "template" nodes instead of "versions", since a v2 config can also include "versions" and was previously being skipped from merging as a result. - Preserve the "versions" node after merging a v1 config, since PortalConfigReader.MergeConfigs does not carry it forward. --- .../Config/Essentials/ConfigReader.cs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs index 40c6c0cf..37cd4eba 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs @@ -135,12 +135,14 @@ namespace PepperDash.Essentials.Core.Config { var parsedConfig = JObject.Parse(fs.ReadToEnd()); - // Check if it's a v2 config (check for "version" node) - // this means it's already merged by the Portal API - // from the v2 config tool - var isV2Config = parsedConfig["versions"] != null; - - if (isV2Config) + // A config is v1 if it has separate "system" and "template" nodes that + // need to be merged. A v2 config is already merged by the Portal API and + // will not have "system"/"template" nodes. This is independent of whether + // a "versions" node is present, which only carries version metadata and + // can appear on either a v1 or v2 config. + var isV1Config = parsedConfig["system"] != null && parsedConfig["template"] != null; + + if (!isV1Config) { Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary."); ConfigObject = parsedConfig.ToObject(); @@ -148,6 +150,8 @@ namespace PepperDash.Essentials.Core.Config return true; } + Debug.LogMessage(LogEventLevel.Information, "Config file is a v1 format, merging system and template."); + // Extract SystemUrl and TemplateUrl into final config output ConfigObject = PortalConfigReader.MergeConfigs(parsedConfig).ToObject(); @@ -160,6 +164,13 @@ namespace PepperDash.Essentials.Core.Config { ConfigObject.TemplateUrl = parsedConfig["template_url"].Value(); } + + // MergeConfigs does not carry the "versions" node forward, so it must be + // applied separately to ensure it's preserved in the merged config. + if (parsedConfig["versions"] != null) + { + ConfigObject.Versions = parsedConfig["versions"].ToObject(); + } } Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config"); From 1e63b10aa4b0bd26742674e4c0edee1050855d57 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 17:14:34 -0500 Subject: [PATCH 62/84] fix: report main assembly's PackageId (PepperDashEssentials) in packageManifest API --- .../GetPackageManifestRequestHandler.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index aa1663a4..f2fac995 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -77,9 +77,15 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers essentials.Version = Global.AssemblyVersion; - // PepperDash_Essentials_Core.dll - same repo/Directory.Build.props as PepperDashEssentials.dll, - // and unlike PluginLoader.EssentialsAssembly, this Assembly reference is never null at runtime. - var essentialsAssembly = typeof(GetPackageManifestRequestHandler).Assembly; + // The main program assembly (AssemblyName/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, + // falling back to this handler's own (Core) assembly if it can't be found. + var essentialsAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(a.GetName().Name, "PepperDashEssentials", StringComparison.OrdinalIgnoreCase)) + ?? typeof(GetPackageManifestRequestHandler).Assembly; var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl")); if (!string.IsNullOrEmpty(repoUrl)) @@ -103,7 +109,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers } else if (string.IsNullOrEmpty(essentials.PackageId)) { - essentials.PackageId = "PepperDash.Essentials"; + essentials.PackageId = "PepperDashEssentials"; } result.Essentials = essentials; From c323c872fc169c4f0529f7bcae5f9182ac4c8ca1 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 17:20:22 -0500 Subject: [PATCH 63/84] fix: match main assembly by PackageId metadata instead of AssemblyName --- .../GetPackageManifestRequestHandler.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index f2fac995..c250ff95 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -77,14 +77,15 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers essentials.Version = Global.AssemblyVersion; - // The main program assembly (AssemblyName/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, - // falling back to this handler's own (Core) assembly if it can't be found. + // 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(a.GetName().Name, "PepperDashEssentials", StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(a => string.Equals(GetAssemblyMetadataValue(a, "PackageId"), "PepperDashEssentials", StringComparison.OrdinalIgnoreCase)) ?? typeof(GetPackageManifestRequestHandler).Assembly; var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl")); From 02216372bc14a097c5830ef2b1f754096f933e09 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 7 Jul 2026 17:41:04 -0500 Subject: [PATCH 64/84] feat(mobile-control): track and validate UI client app versions Extend the /system/clientJoined handler to accept an optional `appVersion` field reported by connecting UI clients (e.g. the React app's build-time APP_VERSION), so Essentials can record and validate what's actually running against the configured versions.touchpanelWrapperApp version. - Add ConnectedClientVersionInfo to capture clientId, roomKey, touchpanelKey, reported/expected app version, and last-seen time. - Add MobileControlSystemController.TrackClientAppVersion(), storing results in a new thread-safe ConnectedClientVersions dictionary and logging a warning on version mismatch. - Expose ConnectedClientVersions as a public read-only property for diagnostics. - Surface reported vs. expected versions per client in the `mobileinfo` console command output. No wire protocol changes required; content is passed as-is over the existing clientJoined message. --- .../ConnectedClientVersionInfo.cs | 47 ++++++++++ .../MobileControlSystemController.cs | 89 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs diff --git a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs new file mode 100644 index 00000000..4107d120 --- /dev/null +++ b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs @@ -0,0 +1,47 @@ +using System; +using Newtonsoft.Json; + +namespace PepperDash.Essentials +{ + /// + /// Represents the version information reported by a connected Mobile Control UI client + /// + public class ConnectedClientVersionInfo + { + /// + /// Gets or sets the client id + /// + [JsonProperty("clientId")] + public string ClientId { get; set; } + + /// + /// Gets or sets the room key the client joined + /// + [JsonProperty("roomKey")] + public string RoomKey { get; set; } + + /// + /// Gets or sets the touchpanel key the client joined as, if any + /// + [JsonProperty("touchpanelKey")] + public string TouchpanelKey { get; set; } + + /// + /// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION) + /// + [JsonProperty("appVersion")] + public string AppVersion { get; set; } + + /// + /// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured + /// + [JsonProperty("expectedAppVersion")] + public string ExpectedAppVersion { get; set; } + + /// + /// Gets or sets the UTC time the client last reported this version + /// + [JsonProperty("lastSeen")] + public DateTime LastSeen { get; set; } + } +} diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index 3d466f5b..91fc55c5 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -69,11 +69,32 @@ namespace PepperDash.Essentials private readonly Dictionary _defaultMessengers = new Dictionary(); + private readonly Dictionary _connectedClientVersions = + new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + private readonly object _connectedClientVersionsLock = new object(); + /// /// Get the custom messengers with subscriptions /// public ReadOnlyDictionary Messengers => new ReadOnlyDictionary(_messengers.Values.OfType().ToDictionary(k => k.Key, v => v)); + /// + /// Gets the most recently reported UI app version for each connected client, keyed by clientId + /// + public ReadOnlyDictionary ConnectedClientVersions + { + get + { + lock (_connectedClientVersionsLock) + { + return new ReadOnlyDictionary( + new Dictionary(_connectedClientVersions) + ); + } + } + } + /// /// Get the default messengers /// @@ -1782,6 +1803,28 @@ namespace PepperDash.Essentials " Not Enabled in Config.\r\n" ); } + + var connectedClientVersions = ConnectedClientVersions; + + if (connectedClientVersions.Count == 0) + { + CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions: None reported yet\r\n"); + } + else + { + CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions:\r\n"); + foreach (var kv in connectedClientVersions) + { + var v = kv.Value; + var match = string.IsNullOrEmpty(v.ExpectedAppVersion) || string.Equals(v.ExpectedAppVersion, v.AppVersion, StringComparison.OrdinalIgnoreCase); + + CrestronConsole.ConsoleCommandResponse( + $" Client: {v.ClientId} Touchpanel: {v.TouchpanelKey} Room: {v.RoomKey}\r\n" + + $" Reported: {v.AppVersion} Expected: {v.ExpectedAppVersion ?? "(not configured)"} Match: {(match ? "Yes" : "NO - MISMATCH")}\r\n" + + $" Last Seen (UTC): {v.LastSeen:yyyy-MM-dd HH:mm:ss}\r\n" + ); + } + } } /// @@ -2181,6 +2224,8 @@ namespace PepperDash.Essentials var roomKey = content["roomKey"].Value(); var touchpanelKey = content.SelectToken("touchpanelKey"); + TrackClientAppVersion(clientId, roomKey, touchpanelKey?.Value(), content.SelectToken("appVersion")?.Value()); + if (_roomCombiner == null) { var message = new MobileControlMessage @@ -2252,6 +2297,50 @@ namespace PepperDash.Essentials SendTouchpanelKey(clientId, touchpanelKey); } + /// + /// Records the app version reported by a connecting UI client (e.g. the mobile control React app's + /// build-time APP_VERSION) and compares it against the configured versions.touchpanelWrapperApp version. + /// + private void TrackClientAppVersion(string clientId, string roomKey, string touchpanelKey, string appVersion) + { + if (string.IsNullOrEmpty(appVersion)) + { + return; + } + + var expectedVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version; + + var info = new ConnectedClientVersionInfo + { + ClientId = clientId, + RoomKey = roomKey, + TouchpanelKey = touchpanelKey, + AppVersion = appVersion, + ExpectedAppVersion = expectedVersion, + LastSeen = DateTime.UtcNow + }; + + lock (_connectedClientVersionsLock) + { + _connectedClientVersions[clientId] = info; + } + + if (!string.IsNullOrEmpty(expectedVersion) && !string.Equals(expectedVersion, appVersion, StringComparison.OrdinalIgnoreCase)) + { + this.LogWarning( + "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}, which does not match configured versions.touchpanelWrapperApp version {expectedVersion}", + clientId, touchpanelKey, appVersion, expectedVersion + ); + } + else + { + this.LogVerbose( + "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}", + clientId, touchpanelKey, appVersion + ); + } + } + private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken) { if (touchpanelKeyToken == null) From 22ef9cf7c226139a22ba2526aa0f7668656212db Mon Sep 17 00:00:00 2001 From: Jason DeVito Date: Wed, 8 Jul 2026 09:54:14 -0500 Subject: [PATCH 65/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Web/RequestHandlers/GetPackageManifestRequestHandler.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index c250ff95..8dc4a751 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -46,8 +46,9 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers context.Response.Write(js, false); context.Response.End(); } - catch (Exception) + 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(); From 02f507ccb11a7cab2e5c69935a5fb49ffaa1bac6 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Wed, 8 Jul 2026 10:07:20 -0500 Subject: [PATCH 66/84] fix(mobile-control): address PR review feedback on version tracking - ConnectedClientVersions now deep-copies each ConnectedClientVersionInfo when snapshotting, so external callers can't mutate the internally tracked, lock-protected instances (Copilot review). - ShowInfo now prints "(not configured)" for an empty ExpectedAppVersion, matching the match/mismatch calculation which already treats empty the same as not-configured (Copilot review). - GetPackageManifestRequestHandler.PopulatePackages filters out null entries from the config-supplied packages list before processing, so a malformed "packages": [null, ...] in user-edited config JSON degrades gracefully instead of throwing (Copilot review). --- .../GetPackageManifestRequestHandler.cs | 6 +++++- .../ConnectedClientVersionInfo.cs | 14 ++++++++++++++ .../MobileControlSystemController.cs | 4 ++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs index 8dc4a751..eeea75cd 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -122,7 +122,11 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers /// private static void PopulatePackages(VersionData result) { - var configPackages = result.Packages ?? new System.Collections.Generic.List(); + // Filter out null entries defensively - the packages list is deserialized from user-editable + // config JSON, so a malformed "packages": [null, ...] shouldn't throw and 500 the endpoint. + var configPackages = (result.Packages ?? new System.Collections.Generic.List()) + .Where(p => p != null) + .ToList(); var matchedConfigPackages = new System.Collections.Generic.HashSet(); var mergedPackages = new System.Collections.Generic.List(); diff --git a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs index 4107d120..68f5e36e 100644 --- a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs +++ b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs @@ -43,5 +43,19 @@ namespace PepperDash.Essentials /// [JsonProperty("lastSeen")] public DateTime LastSeen { get; set; } + + /// + /// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate + /// without affecting the internally tracked instance + /// + public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo + { + ClientId = ClientId, + RoomKey = RoomKey, + TouchpanelKey = TouchpanelKey, + AppVersion = AppVersion, + ExpectedAppVersion = ExpectedAppVersion, + LastSeen = LastSeen + }; } } diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index 91fc55c5..c17cf3b9 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -89,7 +89,7 @@ namespace PepperDash.Essentials lock (_connectedClientVersionsLock) { return new ReadOnlyDictionary( - new Dictionary(_connectedClientVersions) + _connectedClientVersions.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()) ); } } @@ -1820,7 +1820,7 @@ namespace PepperDash.Essentials CrestronConsole.ConsoleCommandResponse( $" Client: {v.ClientId} Touchpanel: {v.TouchpanelKey} Room: {v.RoomKey}\r\n" + - $" Reported: {v.AppVersion} Expected: {v.ExpectedAppVersion ?? "(not configured)"} Match: {(match ? "Yes" : "NO - MISMATCH")}\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" ); } From a8ec83106450d32eef4d9006fd851e295345e37d Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Mon, 13 Jul 2026 12:58:52 -0700 Subject: [PATCH 67/84] feat: add IHasDspPresetSave interface for DSP preset management --- .../Devices/IHasDspPresetSave.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs diff --git a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs new file mode 100644 index 00000000..3cdd348b --- /dev/null +++ b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs @@ -0,0 +1,14 @@ +namespace PepperDash.Essentials.Core +{ + /// + /// Defines the contract for IHasDspPresetSave + /// + public interface IHasDspPresetSave : IDspPresets // recall + save + { + /// + /// Saves the DSP preset by key + /// + /// + void SavePresetByKey(string presetKey); // mirrors RecallPreset(string key) + } +} \ No newline at end of file From d4284bd59f2137a10cd044038f32e599cc680dd7 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Mon, 13 Jul 2026 16:08:10 -0700 Subject: [PATCH 68/84] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs index 3cdd348b..fc71d28d 100644 --- a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs +++ b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs @@ -3,7 +3,7 @@ namespace PepperDash.Essentials.Core /// /// Defines the contract for IHasDspPresetSave /// - public interface IHasDspPresetSave : IDspPresets // recall + save + public interface IHasDspPresetSave : IDspPresets { /// /// Saves the DSP preset by key From 4dca16e9bd0fac08fe6d2c79650516bd74a0cc29 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Mon, 13 Jul 2026 16:09:45 -0700 Subject: [PATCH 69/84] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs index fc71d28d..c628129e 100644 --- a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs +++ b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs @@ -6,9 +6,9 @@ namespace PepperDash.Essentials.Core public interface IHasDspPresetSave : IDspPresets { /// - /// Saves the DSP preset by key + /// Saves the preset by key /// - /// - void SavePresetByKey(string presetKey); // mirrors RecallPreset(string key) + /// key of preset to save + void SavePreset(string key); } } \ No newline at end of file From aa76551500380bd705e3dbe13c977e3e962a8556 Mon Sep 17 00:00:00 2001 From: Erik Meyer Date: Thu, 16 Jul 2026 08:40:33 -0400 Subject: [PATCH 70/84] fix: guard CecPortController against null StreamCec and lazily subscribe --- .../Comm and IR/CecPortController.cs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs index 298bbc9b..4b381dd0 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs @@ -39,6 +39,8 @@ namespace PepperDash.Essentials.Core ICec Port; + bool _cecSubscribed; + /// /// Constructor /// @@ -54,7 +56,7 @@ namespace PepperDash.Essentials.Core { Port = postActivationFunc(config); - Port.StreamCec.CecChange += StreamCec_CecChange; + TryEnsureCecSubscription(); }); } @@ -68,7 +70,29 @@ namespace PepperDash.Essentials.Core { Port = port; + TryEnsureCecSubscription(); + } + + /// + /// Subscribes to the CEC change event once is available. + /// Safe to call repeatedly; the subscription is only wired a single time. This allows the + /// receive path to self-heal if StreamCec was null at construction (e.g. the underlying + /// device had not yet come online). + /// + void TryEnsureCecSubscription() + { + if (_cecSubscribed) + return; + + if (Port?.StreamCec == null) + { + Debug.LogMessage(LogEventLevel.Warning, this, "StreamCec is not available; CEC feedback is deferred until the device is ready"); + return; + } + Port.StreamCec.CecChange += new CecChangeEventHandler(StreamCec_CecChange); + _cecSubscribed = true; + Debug.LogMessage(LogEventLevel.Information, this, "Subscribed to CEC feedback"); } void StreamCec_CecChange(Cec cecDevice, CecEventArgs args) @@ -104,8 +128,9 @@ namespace PepperDash.Essentials.Core /// public void SendText(string text) { - if (Port == null) + if (Port?.StreamCec == null) return; + TryEnsureCecSubscription(); this.PrintSentText(text); Port.StreamCec.Send.StringValue = text; } @@ -115,8 +140,9 @@ namespace PepperDash.Essentials.Core /// public void SendBytes(byte[] bytes) { - if (Port == null) + if (Port?.StreamCec == null) return; + TryEnsureCecSubscription(); var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); this.PrintSentBytes(bytes); Debug.LogMessage(LogEventLevel.Information, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes)); From f4cb17314d4bc8a82f0eb6b58a16779d736d4d31 Mon Sep 17 00:00:00 2001 From: erikdred <88980320+erikdred@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:21:45 -0400 Subject: [PATCH 71/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../GetFeedbacksForDeviceRequestHandler.cs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs index 7d947e19..9831fa91 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs @@ -52,20 +52,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers var device = DeviceManager.GetDeviceForKey(deviceObj.ToString()) as IHasFeedback; if (device == null) { - context.Response.StatusCode = 200; - context.Response.StatusDescription = "OK"; - context.Response.ContentType = "application/json"; - context.Response.ContentEncoding = System.Text.Encoding.UTF8; - var resp = new - { - BoolValues = Array.Empty(), - IntValues = Array.Empty(), - SerialValues = Array.Empty() - }; - var respJs = JsonConvert.SerializeObject(resp, Formatting.Indented); - - context.Response.Write(respJs, false); - + context.Response.StatusCode = 404; + context.Response.StatusDescription = "Not Found"; context.Response.End(); return; From fbf4c56403e9ec7ac51e0428ece3c4ad42201435 Mon Sep 17 00:00:00 2001 From: erikdred <88980320+erikdred@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:23:18 -0400 Subject: [PATCH 72/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Messengers/DeviceStateMessageBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs index 4241b69c..c5c0ab65 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/DeviceStateMessageBase.cs @@ -13,7 +13,7 @@ namespace PepperDash.Essentials.AppServer.Messengers /// The interfaces implmented by the device sending the messsage /// [JsonProperty("interfaces", NullValueHandling = NullValueHandling.Ignore)] - [Obsolete("Interfaces 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")] + [Obsolete("Interfaces are no longer supported and will be removed in a future release. Interfaces for all devices are now retrieved via the /joinroom endpoint in the MobileControlWebsocketServer")] public List Interfaces { get; private set; } /// From 36c15b8d959cd17c61399fd3fdd0190641a724cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:25:25 +0000 Subject: [PATCH 73/84] Clarify CecPortController subscription XML comment --- .../Comm and IR/CecPortController.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs index 4b381dd0..a5aa0d13 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs @@ -75,9 +75,9 @@ namespace PepperDash.Essentials.Core /// /// Subscribes to the CEC change event once is available. - /// Safe to call repeatedly; the subscription is only wired a single time. This allows the - /// receive path to self-heal if StreamCec was null at construction (e.g. the underlying - /// device had not yet come online). + /// Safe to call repeatedly; the subscription is only wired a single time. + /// If StreamCec is null during construction, this is retried when send methods invoke + /// this method later. /// void TryEnsureCecSubscription() { From 14cd08207621ee4666c6467e07ebba485fbe1817 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:26:24 +0000 Subject: [PATCH 74/84] Remove redundant IRoutingInputsOutputs filtering in route mapping loops --- src/PepperDash.Essentials.Core/Routing/Extensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 2b5b3c8c..303a0194 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -348,9 +348,9 @@ namespace PepperDash.Essentials.Core var sources = DeviceManager.AllDevices.OfType() .Where(d => !(d is IRoutingInputsOutputs)).ToList(); - foreach (var sink in sinks.Where(d => !(d is IRoutingInputsOutputs))) + foreach (var sink in sinks) { - foreach (var source in sources.Where(d => !(d is IRoutingInputsOutputs))) + foreach (var source in sources) { foreach (var inputPort in sink.InputPorts) { From 6949253b1a447f63dcb78a95dcf2e1920ec285be Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Wed, 29 Jul 2026 14:39:36 -0700 Subject: [PATCH 75/84] feat: add TS-1080 and TSW-1080 touchpanel support Add mctsw1080 and mcts1080 Mobile Control touchpanel types and map them to the Tsw1080 and Ts1080 hardware classes in the touchpanel controller factory. Bump the Crestron SimplSharp SDK packages (Library, ProgramLibrary, Program) from 2.21.90 to 2.21.274 across all projects. The Tsw1080 and Ts1080 device classes do not exist in the SDK prior to 2.21.274, so the SDK bump is required for the new panel types to compile. --- src/PepperDash.Core/PepperDash.Core.csproj | 2 +- .../PepperDash.Essentials.Core.csproj | 2 +- .../PepperDash.Essentials.Devices.Common.csproj | 2 +- .../PepperDash.Essentials.MobileControl.Messengers.csproj | 2 +- .../PepperDash.Essentials.MobileControl.csproj | 2 +- .../Touchpanel/MobileControlTouchpanelController.cs | 4 +++- src/PepperDash.Essentials/PepperDash.Essentials.csproj | 2 +- 7 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/PepperDash.Core/PepperDash.Core.csproj b/src/PepperDash.Core/PepperDash.Core.csproj index daa5c6da..747aefb6 100644 --- a/src/PepperDash.Core/PepperDash.Core.csproj +++ b/src/PepperDash.Core/PepperDash.Core.csproj @@ -43,7 +43,7 @@ - + diff --git a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj index 251ba316..b7f333bd 100644 --- a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj +++ b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj @@ -25,7 +25,7 @@ bin\$(Configuration)\PepperDash_Essentials_Core.xml - + diff --git a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj index 7be4372d..317fa2d3 100644 --- a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj +++ b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj @@ -29,6 +29,6 @@ - + \ No newline at end of file diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj index d13d1a09..a9223061 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj +++ b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj @@ -33,7 +33,7 @@ - + diff --git a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj index 235e0899..b0e2dd9f 100644 --- a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj +++ b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj @@ -38,7 +38,7 @@ - + diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index 5830782d..9e6230d3 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -740,7 +740,7 @@ namespace PepperDash.Essentials.Touchpanel /// public MobileControlTouchpanelControllerFactory() { - TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mcxpanel", "mcdge1000" }; + TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mctsw1080", "mcts1080", "mcxpanel", "mcdge1000" }; MinimumEssentialsFrameworkVersion = "2.0.0"; factories = new Dictionary> @@ -765,6 +765,8 @@ namespace PepperDash.Essentials.Touchpanel {"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)}, {"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)}, {"ts1070", (id, controlSystem, projectName) => new Ts1070(id, controlSystem)}, + {"tsw1080", (id, controlSystem, projectName) => new Tsw1080(id, controlSystem)}, + {"ts1080", (id, controlSystem, projectName) => new Ts1080(id, controlSystem)}, {"dge1000", (id, controlSystem, projectName) => new Dge1000(id, controlSystem)} }; } diff --git a/src/PepperDash.Essentials/PepperDash.Essentials.csproj b/src/PepperDash.Essentials/PepperDash.Essentials.csproj index 20a42ffd..cb83bf84 100644 --- a/src/PepperDash.Essentials/PepperDash.Essentials.csproj +++ b/src/PepperDash.Essentials/PepperDash.Essentials.csproj @@ -48,7 +48,7 @@ - + From 1719de195f00f2a20c7cc10f24db82741c256891 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Thu, 30 Jul 2026 14:10:15 -0700 Subject: [PATCH 76/84] feat: implement ICommunicationMonitor on MobileControlTouchpanelController for Health page visibility --- .../MobileControlTouchpanelController.cs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index 5830782d..d3350ac9 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -25,11 +25,16 @@ namespace PepperDash.Essentials.Touchpanel /// Mobile Control touchpanel controller that provides app control, Zoom integration, /// and mobile control functionality for Crestron touchpanels. /// - public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme + public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme, ICommunicationMonitor { private readonly MobileControlTouchpanelProperties localConfig; private IMobileControlRoomMessenger _bridge; + /// + /// Gets the CommunicationMonitor tracking the panel's online/offline state + /// + public StatusMonitorBase CommunicationMonitor { get; private set; } + private string _appUrl; /// @@ -128,6 +133,11 @@ namespace PepperDash.Essentials.Touchpanel { localConfig = config; + if (panel != null) + { + CommunicationMonitor = new CrestronGenericBaseCommunicationMonitor(this, panel, 120000, 300000); + } + AddPostActivationAction(SubscribeForMobileControlUpdates); ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme); @@ -366,6 +376,8 @@ namespace PepperDash.Essentials.Touchpanel /// public override bool CustomActivate() { + CommunicationMonitor?.Start(); + var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this); var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this); @@ -393,6 +405,17 @@ namespace PepperDash.Essentials.Touchpanel return base.CustomActivate(); } + /// + /// Stops the CommunicationMonitor on deactivation. + /// + /// True if deactivation was successful; otherwise, false. + public override bool Deactivate() + { + CommunicationMonitor?.Stop(); + + return base.Deactivate(); + } + /// /// Handles device extender signal changes for system reserved signals. /// From d21cef0e60f25396a6adfb5d77543fb574884f80 Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Thu, 30 Jul 2026 16:22:30 -0700 Subject: [PATCH 77/84] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Touchpanel/MobileControlTouchpanelController.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index d3350ac9..eef69015 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -35,6 +35,18 @@ namespace PepperDash.Essentials.Touchpanel /// 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; /// From 3c9f5e95e0b6c0029d17e4c64757fa19418870bd Mon Sep 17 00:00:00 2001 From: jkdevito Date: Tue, 21 Jul 2026 21:50:06 -0500 Subject: [PATCH 78/84] feat(mobile-control): support https/wss for direct server URLs when Secure is true - Add HttpScheme/WsScheme helpers driven by directServer.Secure - Replace hardcoded http/ws literals in UserAppUrlPrefix, touchpanel app URL, _config.local.json ApiPath, remote logging POST, join-response WebSocketUrl and UserAppUrl - Pass Secure flag into the HttpServer constructor so the listener actually negotiates TLS when a cert is configured - Drop TLS 1.1 from EnabledSslProtocols, keep TLS 1.2 (TLS 1.3 is not defined in the net472 SslProtocols enum used by this project) - MobileControlTouchpanelController: rewrite the app-URL IP regex to match and preserve either http or https instead of assuming http --- .../MobileControlTouchpanelController.cs | 8 ++--- .../MobileControlWebsocketServer.cs | 31 +++++++++++++------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index ec241a33..dcd0d56e 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -554,15 +554,15 @@ namespace PepperDash.Essentials.Touchpanel return false; }) ? csIpAddress.ToString() : processorIp; - var match = Regex.Match(url, @"^http://([^:/]+):\d+/mc/app\?token=.+$"); + var match = Regex.Match(url, @"^(https?)://([^:/]+):\d+/mc/app\?token=.+$"); if (match.Success) { - string ipa = match.Groups[1].Value; + string ipa = match.Groups[2].Value; // ip will be "192.168.1.100" } - // replace ipa with ip but leave the rest of the string intact - var updatedUrl = Regex.Replace(url, @"^http://[^:/]+", $"http://{ip}"); + // replace the host but preserve whatever scheme (http/https) is already present in the URL + var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}"); this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl); diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index 1c9ed37a..3bcbcdf6 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -127,6 +127,16 @@ namespace PepperDash.Essentials.WebSocketServer /// public int Port { get; private set; } + /// + /// Gets the HTTP scheme to use for generated URLs, based on whether the direct server is configured as secure + /// + private string HttpScheme => _parent.Config.DirectServer.Secure ? "https" : "http"; + + /// + /// Gets the WebSocket scheme to use for generated URLs, based on whether the direct server is configured as secure + /// + private string WsScheme => _parent.Config.DirectServer.Secure ? "wss" : "ws"; + /// /// Gets the user app URL prefix /// @@ -134,7 +144,8 @@ namespace PepperDash.Essentials.WebSocketServer { get { - return string.Format("http://{0}:{1}{2}?token=", + return string.Format("{0}://{1}:{2}{3}?token=", + HttpScheme, CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), Port, _userAppBaseHref); @@ -273,7 +284,7 @@ namespace PepperDash.Essentials.WebSocketServer { base.Initialize(); - _server = new HttpServer(Port, false); + _server = new HttpServer(Port, _parent.Config.DirectServer.Secure); _server.OnGet += Server_OnGet; @@ -291,7 +302,7 @@ namespace PepperDash.Essentials.WebSocketServer { ClientCertificateRequired = false, CheckCertificateRevocation = false, - EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 + EnabledSslProtocols = SslProtocols.Tls12 }; } @@ -403,11 +414,11 @@ namespace PepperDash.Essentials.WebSocketServer ip = csIpAddress.ToString(); } - var appUrl = $"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"; + var appUrl = $"{HttpScheme}://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"; this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key); - touchpanel.Touchpanel.SetAppUrl($"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"); + touchpanel.Touchpanel.SetAppUrl(appUrl); } } @@ -487,7 +498,7 @@ namespace PepperDash.Essentials.WebSocketServer { var config = new MobileControlApplicationConfig { - ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port), + ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, _parent.Config.DirectServer.Port), GatewayAppPath = "", LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png", EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false, @@ -1098,7 +1109,7 @@ namespace PepperDash.Essentials.WebSocketServer res.StatusCode = 200; res.Close(); - var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") + var logRequest = new HttpRequestMessage(HttpMethod.Post, $"{HttpScheme}://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; @@ -1213,8 +1224,7 @@ namespace PepperDash.Essentials.WebSocketServer this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now); // Construct WebSocket URL with clientId query parameter - var wsProtocol = "ws"; - var wsUrl = $"{wsProtocol}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}"; + var wsUrl = $"{WsScheme}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}"; // Construct the response object JoinResponse jRes = new JoinResponse @@ -1226,7 +1236,8 @@ namespace PepperDash.Essentials.WebSocketServer Config = _parent.GetConfigWithPluginVersion(), CodeExpires = new DateTime().AddYears(1), UserCode = bridge.UserCode, - UserAppUrl = string.Format("http://{0}:{1}/mc/app", + UserAppUrl = string.Format("{0}://{1}:{2}/mc/app", + HttpScheme, CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), Port), WebSocketUrl = wsUrl, From 3afc73a4469cd9b3101d7a17f17406e724547964 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Thu, 13 Aug 2026 17:50:26 -0500 Subject: [PATCH 79/84] fix(mobile-control): address PR review feedback - Use the actual listening Port instead of raw DirectServer.Port config when building the app URL and API path, so advertised URLs match the listener even when the configured port is 0. - Keep the remote logging endpoint on http:// since there is no dedicated secure flag for the log collector. - Remove dead Regex.Match/ipa variable in GetUrlWithCorrectIp; the scheme-preserving Regex.Replace below it already handles this. --- .../Touchpanel/MobileControlTouchpanelController.cs | 7 ------- .../WebSocketServer/MobileControlWebsocketServer.cs | 7 ++++--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index dcd0d56e..262cb34e 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -554,13 +554,6 @@ namespace PepperDash.Essentials.Touchpanel return false; }) ? csIpAddress.ToString() : processorIp; - var match = Regex.Match(url, @"^(https?)://([^:/]+):\d+/mc/app\?token=.+$"); - if (match.Success) - { - string ipa = match.Groups[2].Value; - // ip will be "192.168.1.100" - } - // replace the host but preserve whatever scheme (http/https) is already present in the URL var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}"); diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index 3bcbcdf6..a948d857 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -414,7 +414,7 @@ namespace PepperDash.Essentials.WebSocketServer ip = csIpAddress.ToString(); } - var appUrl = $"{HttpScheme}://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}"; + var appUrl = $"{HttpScheme}://{ip}:{Port}/mc/app?token={touchpanel.Key}"; this.LogVerbose("Sending URL {appUrl} to touchpanel {touchpanelKey}", appUrl, touchpanel.Touchpanel.Key); @@ -498,7 +498,7 @@ namespace PepperDash.Essentials.WebSocketServer { var config = new MobileControlApplicationConfig { - ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, _parent.Config.DirectServer.Port), + ApiPath = string.Format("{0}://{1}:{2}/mc/api", HttpScheme, processorIp, Port), GatewayAppPath = "", LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png", EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false, @@ -1109,7 +1109,8 @@ namespace PepperDash.Essentials.WebSocketServer res.StatusCode = 200; res.Close(); - var logRequest = new HttpRequestMessage(HttpMethod.Post, $"{HttpScheme}://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") + // remote log collector has no dedicated secure flag; keep it on http regardless of DirectServer.Secure + var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; From 85a4b635f69e9427c14d3cac9a93188dad2c3f5a Mon Sep 17 00:00:00 2001 From: Jonathan Arndt Date: Wed, 19 Aug 2026 10:24:23 -0700 Subject: [PATCH 80/84] fix(mobile-control): update version and improve response caching for join requests --- src/Directory.Build.props | 2 +- .../WebSocketServer/MobileControlWebsocketServer.cs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ab9f2732..235fee48 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 2.36.6-local + 2.42.1-local $(Version) PepperDash Technology PepperDash Technology diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index a948d857..c96dc116 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -1162,6 +1162,11 @@ namespace PepperDash.Essentials.WebSocketServer var qp = req.QueryString; var token = qp["token"]; + // Each join mints a single-use clientId; the panel webview must never replay a cached + // response, or it reconnects forever with an already-consumed id (1008 loop). + res.Headers.Add("Cache-Control", "no-store"); + res.Headers.Add("Pragma", "no-cache"); + this.LogVerbose("Join Room Request with token: {token}", token); byte[] body; @@ -1263,6 +1268,8 @@ namespace PepperDash.Essentials.WebSocketServer { res.StatusCode = 200; res.ContentType = "application/json"; + res.Headers.Add("Cache-Control", "no-store"); + res.Headers.Add("Pragma", "no-cache"); var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion }; var message = JsonConvert.SerializeObject(version); this.LogVerbose("{message}", message); From ec9753b19e824f5c9113004feca1397b489d46bc Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 20 Aug 2026 10:26:09 -0600 Subject: [PATCH 81/84] fix(ssh-client): improve error handling for SSH connection exceptions --- src/PepperDash.Core/Comm/GenericSshClient.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs index df44ab51..16406978 100644 --- a/src/PepperDash.Core/Comm/GenericSshClient.cs +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -287,13 +287,7 @@ namespace PepperDash.Core } catch (SshConnectionException e) { - var ie = e.InnerException; // The details are inside!! - - if (ie is SocketException) - { - this.LogError("CONNECTION failure: Cannot reach host"); - this.LogVerbose(ie, "Exception details: "); - } + var ie = e.InnerException; // The details are inside, when present - remote can close the connection with no inner exception at all if (ie is System.Net.Sockets.SocketException socketException) { @@ -301,15 +295,15 @@ namespace PepperDash.Core Hostname, Port); this.LogVerbose(socketException, "SocketException details: "); } - if (ie is SshAuthenticationException) + else if (ie is SshAuthenticationException) { this.LogError("Authentication failure for username {userName}", Username); this.LogVerbose(ie, "AuthenticationException details: "); } else { - this.LogError("Error on connect: {error}", ie.Message); - this.LogVerbose(ie, "Exception details: "); + this.LogError("Error on connect: {error}", ie?.Message ?? e.Message); + this.LogVerbose(ie ?? (Exception)e, "Exception details: "); } disconnectLogged = true; From 44e5ff4c3e04b343c856cb4537048ba2cc4c4f47 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 20 Aug 2026 10:47:30 -0600 Subject: [PATCH 82/84] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/PepperDash.Core/Comm/GenericSshClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs index 16406978..5a83fcad 100644 --- a/src/PepperDash.Core/Comm/GenericSshClient.cs +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -303,7 +303,7 @@ namespace PepperDash.Core else { this.LogError("Error on connect: {error}", ie?.Message ?? e.Message); - this.LogVerbose(ie ?? (Exception)e, "Exception details: "); + this.LogVerbose(ie ?? e, "Exception details: "); } disconnectLogged = true; From a55e4811b3e64d43015d00d9e9d0cc8461844076 Mon Sep 17 00:00:00 2001 From: Robert Sanders Date: Thu, 20 Aug 2026 16:18:04 -0400 Subject: [PATCH 83/84] fix(core-ssh): stop reconnect loop during stopprog --- src/PepperDash.Core/Comm/GenericSshClient.cs | 35 ++++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs index 5a83fcad..a758378f 100644 --- a/src/PepperDash.Core/Comm/GenericSshClient.cs +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -151,6 +151,8 @@ namespace PepperDash.Core // Thread-safety lock for state changes private readonly object _stateLock = new object(); + private volatile bool _isProgramStopping; + private bool disconnectLogged = false; /// @@ -207,11 +209,9 @@ namespace PepperDash.Core { if (programEventType == eProgramStatusEventType.Stopping) { - if (client != null) - { - this.LogDebug("Program stopping. Closing connection"); - Disconnect(); - } + _isProgramStopping = true; + this.LogDebug("Program stopping. Closing connection"); + Disconnect(); } } @@ -230,6 +230,13 @@ namespace PepperDash.Core ConnectEnabled = true; + if (_isProgramStopping) + { + this.LogDebug("Skipping connect because program is stopping"); + ConnectEnabled = false; + return; + } + try { connectLock.Wait(); @@ -308,7 +315,7 @@ namespace PepperDash.Core disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -320,7 +327,7 @@ namespace PepperDash.Core disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -332,7 +339,7 @@ namespace PepperDash.Core this.LogVerbose(e, "Exception details: "); disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -467,7 +474,7 @@ namespace PepperDash.Core { connectLock.Release(); } - if (AutoReconnect && ConnectEnabled) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -510,7 +517,10 @@ namespace PepperDash.Core this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim()); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - StartReconnectTimer(); + if (ConnectEnabled && !_isProgramStopping) + { + StartReconnectTimer(); + } } catch (Exception ex) { @@ -543,7 +553,10 @@ namespace PepperDash.Core this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes)); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - StartReconnectTimer(); + if (ConnectEnabled && !_isProgramStopping) + { + StartReconnectTimer(); + } } catch (Exception ex) { From 4ca59e3186c6404ec47f75c6c78c6916efb5dfc9 Mon Sep 17 00:00:00 2001 From: Robert Sanders Date: Thu, 20 Aug 2026 16:39:20 -0400 Subject: [PATCH 84/84] fix(core-ssh): harden GenericSshClient shutdown reconnect guards --- src/PepperDash.Core/Comm/GenericSshClient.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs index a758378f..546a2a67 100644 --- a/src/PepperDash.Core/Comm/GenericSshClient.cs +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -228,15 +228,14 @@ namespace PepperDash.Core return; } - ConnectEnabled = true; - if (_isProgramStopping) { this.LogDebug("Skipping connect because program is stopping"); - ConnectEnabled = false; return; } + ConnectEnabled = true; + try { connectLock.Wait(); @@ -517,7 +516,7 @@ namespace PepperDash.Core this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim()); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (ConnectEnabled && !_isProgramStopping) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { StartReconnectTimer(); } @@ -553,7 +552,7 @@ namespace PepperDash.Core this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes)); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (ConnectEnabled && !_isProgramStopping) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { StartReconnectTimer(); }