diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7435df6f..235fee48 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,11 +1,11 @@ - 2.29.0-local + 2.42.1-local $(Version) PepperDash Technology PepperDash Technology PepperDash Essentials - Copyright © 2025 + Copyright © 2026 https://github.com/PepperDash/Essentials git Crestron; 4series @@ -20,4 +20,7 @@ + + + diff --git a/src/PepperDash.Core/Comm/GenericSshClient.cs b/src/PepperDash.Core/Comm/GenericSshClient.cs index df44ab51..546a2a67 100644 --- a/src/PepperDash.Core/Comm/GenericSshClient.cs +++ b/src/PepperDash.Core/Comm/GenericSshClient.cs @@ -151,6 +151,8 @@ namespace PepperDash.Core // Thread-safety lock for state changes private readonly object _stateLock = new object(); + private volatile bool _isProgramStopping; + private bool disconnectLogged = false; /// @@ -207,11 +209,9 @@ namespace PepperDash.Core { if (programEventType == eProgramStatusEventType.Stopping) { - if (client != null) - { - this.LogDebug("Program stopping. Closing connection"); - Disconnect(); - } + _isProgramStopping = true; + this.LogDebug("Program stopping. Closing connection"); + Disconnect(); } } @@ -228,6 +228,12 @@ namespace PepperDash.Core return; } + if (_isProgramStopping) + { + this.LogDebug("Skipping connect because program is stopping"); + return; + } + ConnectEnabled = true; try @@ -287,13 +293,7 @@ namespace PepperDash.Core } catch (SshConnectionException e) { - var ie = e.InnerException; // The details are inside!! - - if (ie is SocketException) - { - this.LogError("CONNECTION failure: Cannot reach host"); - this.LogVerbose(ie, "Exception details: "); - } + var ie = e.InnerException; // The details are inside, when present - remote can close the connection with no inner exception at all if (ie is System.Net.Sockets.SocketException socketException) { @@ -301,20 +301,20 @@ namespace PepperDash.Core Hostname, Port); this.LogVerbose(socketException, "SocketException details: "); } - if (ie is SshAuthenticationException) + else if (ie is SshAuthenticationException) { this.LogError("Authentication failure for username {userName}", Username); this.LogVerbose(ie, "AuthenticationException details: "); } else { - this.LogError("Error on connect: {error}", ie.Message); - this.LogVerbose(ie, "Exception details: "); + this.LogError("Error on connect: {error}", ie?.Message ?? e.Message); + this.LogVerbose(ie ?? e, "Exception details: "); } disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {autoReconnect}, {autoReconnectInterval}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -326,7 +326,7 @@ namespace PepperDash.Core disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -338,7 +338,7 @@ namespace PepperDash.Core this.LogVerbose(e, "Exception details: "); disconnectLogged = true; KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - if (AutoReconnect) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -473,7 +473,7 @@ namespace PepperDash.Core { connectLock.Release(); } - if (AutoReconnect && ConnectEnabled) + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) { this.LogDebug("Checking autoreconnect: {0}, {1}ms", AutoReconnect, AutoReconnectIntervalMs); StartReconnectTimer(); @@ -516,7 +516,10 @@ namespace PepperDash.Core this.LogError("ObjectDisposedException sending '{message}'. Restarting connection...", text.Trim()); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - StartReconnectTimer(); + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) + { + StartReconnectTimer(); + } } catch (Exception ex) { @@ -549,7 +552,10 @@ namespace PepperDash.Core this.LogException(ex, "ObjectDisposedException sending {message}", ComTextHelper.GetEscapedText(bytes)); KillClient(SocketStatus.SOCKET_STATUS_CONNECT_FAILED); - StartReconnectTimer(); + if (AutoReconnect && ConnectEnabled && !_isProgramStopping) + { + StartReconnectTimer(); + } } catch (Exception ex) { diff --git a/src/PepperDash.Core/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/Comm and IR/CecPortController.cs b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs index 298bbc9b..a5aa0d13 100644 --- a/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs +++ b/src/PepperDash.Essentials.Core/Comm and IR/CecPortController.cs @@ -39,6 +39,8 @@ namespace PepperDash.Essentials.Core ICec Port; + bool _cecSubscribed; + /// /// Constructor /// @@ -54,7 +56,7 @@ namespace PepperDash.Essentials.Core { Port = postActivationFunc(config); - Port.StreamCec.CecChange += StreamCec_CecChange; + TryEnsureCecSubscription(); }); } @@ -68,7 +70,29 @@ namespace PepperDash.Essentials.Core { Port = port; + TryEnsureCecSubscription(); + } + + /// + /// Subscribes to the CEC change event once is available. + /// Safe to call repeatedly; the subscription is only wired a single time. + /// If StreamCec is null during construction, this is retried when send methods invoke + /// this method later. + /// + void TryEnsureCecSubscription() + { + if (_cecSubscribed) + return; + + if (Port?.StreamCec == null) + { + Debug.LogMessage(LogEventLevel.Warning, this, "StreamCec is not available; CEC feedback is deferred until the device is ready"); + return; + } + Port.StreamCec.CecChange += new CecChangeEventHandler(StreamCec_CecChange); + _cecSubscribed = true; + Debug.LogMessage(LogEventLevel.Information, this, "Subscribed to CEC feedback"); } void StreamCec_CecChange(Cec cecDevice, CecEventArgs args) @@ -104,8 +128,9 @@ namespace PepperDash.Essentials.Core /// public void SendText(string text) { - if (Port == null) + if (Port?.StreamCec == null) return; + TryEnsureCecSubscription(); this.PrintSentText(text); Port.StreamCec.Send.StringValue = text; } @@ -115,8 +140,9 @@ namespace PepperDash.Essentials.Core /// public void SendBytes(byte[] bytes) { - if (Port == null) + if (Port?.StreamCec == null) return; + TryEnsureCecSubscription(); var text = Encoding.GetEncoding(28591).GetString(bytes, 0, bytes.Length); this.PrintSentBytes(bytes); Debug.LogMessage(LogEventLevel.Information, this, "Sending {0} bytes: '{1}'", bytes.Length, ComTextHelper.GetEscapedText(bytes)); diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs index 40c6c0cf..37cd4eba 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs @@ -135,12 +135,14 @@ namespace PepperDash.Essentials.Core.Config { var parsedConfig = JObject.Parse(fs.ReadToEnd()); - // Check if it's a v2 config (check for "version" node) - // this means it's already merged by the Portal API - // from the v2 config tool - var isV2Config = parsedConfig["versions"] != null; - - if (isV2Config) + // A config is v1 if it has separate "system" and "template" nodes that + // need to be merged. A v2 config is already merged by the Portal API and + // will not have "system"/"template" nodes. This is independent of whether + // a "versions" node is present, which only carries version metadata and + // can appear on either a v1 or v2 config. + var isV1Config = parsedConfig["system"] != null && parsedConfig["template"] != null; + + if (!isV1Config) { Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary."); ConfigObject = parsedConfig.ToObject(); @@ -148,6 +150,8 @@ namespace PepperDash.Essentials.Core.Config return true; } + Debug.LogMessage(LogEventLevel.Information, "Config file is a v1 format, merging system and template."); + // Extract SystemUrl and TemplateUrl into final config output ConfigObject = PortalConfigReader.MergeConfigs(parsedConfig).ToObject(); @@ -160,6 +164,13 @@ namespace PepperDash.Essentials.Core.Config { ConfigObject.TemplateUrl = parsedConfig["template_url"].Value(); } + + // MergeConfigs does not carry the "versions" node forward, so it must be + // applied separately to ensure it's preserved in the merged config. + if (parsedConfig["versions"] != null) + { + ConfigObject.Versions = parsedConfig["versions"].ToObject(); + } } Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config"); diff --git a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs index 6ffe07d2..44275ef0 100644 --- a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs +++ b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs @@ -105,6 +105,7 @@ namespace PepperDash.Essentials.Core.Config /// /// Gets or sets the Versions /// + [JsonProperty("versions")] public VersionData Versions { get; set; } /// @@ -134,12 +135,25 @@ namespace PepperDash.Essentials.Core.Config [JsonProperty("packages")] public List Packages { get; set; } + /// + /// Gets or sets the touchpanel wrapper app version + /// + [JsonProperty("touchpanelWrapperApp")] + public NugetVersion TouchpanelWrapperApp { get; set; } + + /// + /// Gets or sets the list of user interface packages + /// + [JsonProperty("userInterfaces")] + public List UserInterfaces { get; set; } + /// /// Initializes a new instance of the class. /// public VersionData() { Packages = new List(); + UserInterfaces = new List(); } } @@ -157,8 +171,20 @@ namespace PepperDash.Essentials.Core.Config /// /// Gets or sets the PackageId /// - [JsonProperty("packageId")] + [JsonProperty("packageId", NullValueHandling = NullValueHandling.Ignore)] public string PackageId { get; set; } + + /// + /// Gets or sets the RepoUrl + /// + [JsonProperty("repoUrl", NullValueHandling = NullValueHandling.Ignore)] + public string RepoUrl { get; set; } + + /// + /// Gets or sets the human-readable name + /// + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] + public string Name { get; set; } } /// diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs new file mode 100644 index 00000000..6cd3835e --- /dev/null +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IHasWirelessSharing.cs @@ -0,0 +1,48 @@ +using System; + +namespace PepperDash.Essentials.Core.DeviceTypeInterfaces +{ + /// + /// Defines the contract for a wireless presentation endpoint that reports whether a wireless + /// sharing session is currently active. Implemented by platforms such as Crestron AirMedia, + /// Mersive Solstice, Barco ClickShare, Miracast/Teams receivers, etc. Allows consumers (e.g. + /// room plugins) to react to wireless sharing activity without taking a dependency on any + /// concrete device implementation. + /// + /// + /// This refers specifically to wireless screen/device mirroring, as distinct from in-call + /// content sharing on a video conference. + /// + public interface IHasWirelessSharing + { + /// + /// Reports whether a wireless sharing session is currently active (content is being presented). + /// + BoolFeedback IsSharingFeedback { get; } + + /// + /// Raised when wireless sharing starts or stops. The event args carry the new sharing state. + /// + event EventHandler SharingChanged; + } + + /// + /// Event arguments describing a change in wireless sharing state. + /// + public class WirelessSharingEventArgs : EventArgs + { + /// + /// True if a wireless sharing session is active (content is being presented), false otherwise. + /// + public bool IsSharing { get; private set; } + + /// + /// Creates a new . + /// + /// True if a wireless sharing session is active, false otherwise. + public WirelessSharingEventArgs(bool isSharing) + { + IsSharing = isSharing; + } + } +} diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs new file mode 100644 index 00000000..6c6e5598 --- /dev/null +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/INetworkSwitchControl.cs @@ -0,0 +1,122 @@ +using System; + +namespace PepperDash.Essentials.Core.DeviceTypeInterfaces +{ + /// + /// Interface for network switches that support VLAN assignment on individual ports. + /// + public interface INetworkSwitchVlanManager + { + /// + /// Returns the current access VLAN ID configured on the port. + /// Return -1 when the value is unavailable (e.g. the switch has not been polled yet + /// or the implementation does not support VLAN queries). + /// + /// Switch port identifier + /// VLAN ID or -1 when unavailable + int GetPortCurrentVlan(string port); + + /// + /// Changes the access VLAN of a single switch port. + /// The implementation is responsible for entering/exiting privileged/config mode. + /// + /// Switch port identifier (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco) + /// Target VLAN ID (1-4093) + void SetPortVlan(string port, uint vlanId); + } + + /// + /// Interface for network switches that support Power over Ethernet (PoE) control on individual ports. + /// + public interface INetworkSwitchPoeManager + { + /// + /// Enables or disables PoE power delivery on a single switch port. + /// The implementation is responsible for entering/exiting privileged/config mode. + /// + /// Switch port identifier + /// True to enable PoE; false to disable PoE + void SetPortPoeState(string port, bool enabled); + } + + /// + /// Standardized interface for network switch devices that support per-port PoE control + /// and VLAN assignment. + /// + public interface INetworkSwitchPoeVlanManager : INetworkSwitchVlanManager, INetworkSwitchPoeManager + { + /// + /// Event that is raised when the state of a switch port changes, such as a VLAN change or PoE state change. + /// + event EventHandler PortStateChanged; + + } + + /// + /// Event arguments for port state changes on a network switch, such as VLAN changes or PoE state changes. + /// + public class NetworkSwitchPortEventArgs : EventArgs + { + /// + /// The identifier of the port that changed state (e.g. "1/0/3" for Netgear, "gi1/0/3" for Cisco). + /// + public string Port { get; private set; } + + /// + /// The type of event that occurred on the port (e.g. VLAN change, PoE enabled/disabled). + /// + public NetworkSwitchPortEventType EventType { get; private set; } + + /// + /// Constructor for NetworkSwitchPortEventArgs + /// + /// The identifier of the port that changed state + /// The type of event that occurred on the port + public NetworkSwitchPortEventArgs(string port, NetworkSwitchPortEventType eventType) + { + Port = port; + EventType = eventType; + } + } + + /// + /// Enumeration of network switch port state change event types (e.g. VLAN changes or PoE state changes). + /// + public enum NetworkSwitchPortEventType + { + /// + /// Indicates that the type of event is unknown or cannot be determined. + /// + Unknown, + + /// + /// Indicates that a VLAN change is in progress on the port, either through a call to SetPortVlan or an external change detected by polling. + /// + VlanChangeInProgress, + + /// + /// Indicates that the access VLAN on a port has changed, either through a successful call to SetPortVlan or an external change detected by polling. + /// + VlanChanged, + + /// + /// Indicates that PoE is being disabled on the port, either through a call to SetPortPoeState or an external change detected by polling. + /// + PoeDisableInProgress, + + /// + /// Indicates that PoE has been disabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling. + /// + PoEDisabled, + + /// + /// Indicates that PoE is being enabled on the port, either through a call to SetPortPoeState or an external change detected by polling. + /// + PoeEnableInProgress, + + /// + /// Indicates that PoE has been enabled on the port, either through a successful call to SetPortPoeState or an external change detected by polling. + /// + PoEEnabled + } +} diff --git a/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs new file mode 100644 index 00000000..c628129e --- /dev/null +++ b/src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs @@ -0,0 +1,14 @@ +namespace PepperDash.Essentials.Core +{ + /// + /// Defines the contract for IHasDspPresetSave + /// + public interface IHasDspPresetSave : IDspPresets + { + /// + /// Saves the preset by key + /// + /// key of preset to save + void SavePreset(string key); + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj index 251ba316..b7f333bd 100644 --- a/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj +++ b/src/PepperDash.Essentials.Core/PepperDash.Essentials.Core.csproj @@ -25,7 +25,7 @@ bin\$(Configuration)\PepperDash_Essentials_Core.xml - + diff --git a/src/PepperDash.Essentials.Core/Routing/Extensions.cs b/src/PepperDash.Essentials.Core/Routing/Extensions.cs index 9b9c458e..303a0194 100644 --- a/src/PepperDash.Essentials.Core/Routing/Extensions.cs +++ b/src/PepperDash.Essentials.Core/Routing/Extensions.cs @@ -343,12 +343,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) { @@ -373,6 +375,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)) @@ -404,6 +410,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); } } @@ -609,10 +619,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(); 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/GetFeedbacksForDeviceRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs index ca9eeb81..9831fa91 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs @@ -1,13 +1,14 @@ -using System.Linq; +using System; +using System.Linq; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core.Web.RequestHandlers; namespace PepperDash.Essentials.Core.Web.RequestHandlers { - /// - /// Represents a GetFeedbacksForDeviceRequestHandler - /// + /// + /// Represents a GetFeedbacksForDeviceRequestHandler + /// public class GetFeedbacksForDeviceRequestHandler : WebApiBaseRequestHandler { /// @@ -76,7 +77,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers Value = feedback.IntValue }; - var stringFeedback = + var stringFeedback = from feedback in device.Feedbacks.OfType() where !string.IsNullOrEmpty(feedback.Key) select new diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs new file mode 100644 index 00000000..eeea75cd --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs @@ -0,0 +1,259 @@ +using System; +using System.Linq; +using System.Reflection; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Config; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Represents a GetPackageManifestRequestHandler + /// + public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler + { + /// + /// Constructor + /// + /// + /// base(true) enables CORS support by default + /// + public GetPackageManifestRequestHandler() + : base(true) + { + } + + /// + /// Handles GET method requests + /// + /// + protected override void HandleGet(HttpCwsContext context) + { + try + { + var result = CloneVersionData(ConfigReader.ConfigObject?.Versions) ?? new VersionData(); + + PopulateEssentials(result); + PopulatePackages(result); + + var js = JsonConvert.SerializeObject(result, Formatting.Indented); + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(js, false); + context.Response.End(); + } + catch (Exception ex) + { + PepperDash.Core.Debug.LogMessage(ex, "Exception handling GET /packageManifest request"); + context.Response.StatusCode = 500; + context.Response.StatusDescription = "Internal Server Error"; + context.Response.End(); + } + } + + /// + /// Deep-copies the config's VersionData so the live config object is never mutated + /// + private static VersionData CloneVersionData(VersionData source) + { + if (source == null) + { + return null; + } + + var json = JsonConvert.SerializeObject(source); + return JsonConvert.DeserializeObject(json); + } + + /// + /// Enriches (or creates) the essentials entry from the loaded PepperDash.Essentials.Core assembly + /// + private static void PopulateEssentials(VersionData result) + { + var essentials = result.Essentials ?? new NugetVersion(); + + essentials.Version = Global.AssemblyVersion; + + // The main program assembly (PackageId "PepperDashEssentials") is what's actually published + // to NuGet, but this handler lives in PepperDash.Essentials.Core, which can't reference that + // project's types directly (Essentials -> Core, not the reverse). PluginLoader.EssentialsAssembly.Assembly + // is unreliable (often left null - see PluginLoader.SetEssentialsAssembly), so look it up + // directly from the loaded AppDomain by its Directory.Build.props-embedded PackageId metadata + // (every project's .csproj sets its own PackageId explicitly), falling back to this handler's + // own (Core) assembly if it can't be found. + var essentialsAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals(GetAssemblyMetadataValue(a, "PackageId"), "PepperDashEssentials", StringComparison.OrdinalIgnoreCase)) + ?? typeof(GetPackageManifestRequestHandler).Assembly; + + var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl")); + if (!string.IsNullOrEmpty(repoUrl)) + { + essentials.RepoUrl = repoUrl; + } + + var name = GetAssemblyProduct(essentialsAssembly); + if (!string.IsNullOrEmpty(name)) + { + essentials.Name = name; + } + + // Prefer the PackageId embedded via Directory.Build.props' AssemblyMetadata item (the + // authoritative source, matching the actual published PackageId) over any config-supplied + // or hardcoded value. + var reflectedPackageId = GetAssemblyMetadataValue(essentialsAssembly, "PackageId"); + if (!string.IsNullOrEmpty(reflectedPackageId)) + { + essentials.PackageId = reflectedPackageId; + } + else if (string.IsNullOrEmpty(essentials.PackageId)) + { + essentials.PackageId = "PepperDashEssentials"; + } + + result.Essentials = essentials; + } + + /// + /// Merges reflection data from loaded plugin assemblies with the config's packages list + /// + private static void PopulatePackages(VersionData result) + { + // Filter out null entries defensively - the packages list is deserialized from user-editable + // config JSON, so a malformed "packages": [null, ...] shouldn't throw and 500 the endpoint. + var configPackages = (result.Packages ?? new System.Collections.Generic.List()) + .Where(p => p != null) + .ToList(); + var matchedConfigPackages = new System.Collections.Generic.HashSet(); + var mergedPackages = new System.Collections.Generic.List(); + + foreach (var loaded in PluginLoader.EssentialsPluginAssemblies.Where(a => a.Assembly != null)) + { + var reflectedVersion = loaded.Version; + if (string.IsNullOrEmpty(reflectedVersion)) + { + // Never emit an entry with no version - the extension's parser drops entries + // whose version isn't a string. + continue; + } + + var reflectedRepoUrl = TrimTrailingGit(GetAssemblyMetadataValue(loaded.Assembly, "RepositoryUrl")); + var reflectedName = GetAssemblyProduct(loaded.Assembly); + + // Plugins built from a Directory.Build.props that embeds + // carry their PackageId + // directly - this is authoritative and should be preferred over the title/name fallback chain. + var reflectedPackageId = GetAssemblyMetadataValue(loaded.Assembly, "PackageId"); + + var assemblyTitle = GetAssemblyTitle(loaded.Assembly); + var assemblyName = loaded.Assembly.GetName().Name; + var assemblyNameNoSeriesSuffix = StripTrailingSeriesSuffix(assemblyName); + + var match = configPackages.FirstOrDefault(p => + !matchedConfigPackages.Contains(p) && + !string.IsNullOrEmpty(p.PackageId) && + (string.Equals(p.PackageId, reflectedPackageId, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyTitle, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyName, StringComparison.OrdinalIgnoreCase) || + string.Equals(p.PackageId, assemblyNameNoSeriesSuffix, StringComparison.OrdinalIgnoreCase))); + + if (match != null) + { + matchedConfigPackages.Add(match); + + mergedPackages.Add(new NugetVersion + { + Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName, + RepoUrl = !string.IsNullOrEmpty(match.RepoUrl) ? match.RepoUrl : reflectedRepoUrl, + PackageId = !string.IsNullOrEmpty(reflectedPackageId) ? reflectedPackageId : match.PackageId, + Version = reflectedVersion + }); + } + else + { + // Loaded but not present (or not matched) in config - emit the reflected PackageId + // when the assembly carries one, otherwise leave it null as before. + mergedPackages.Add(new NugetVersion + { + Name = reflectedName, + RepoUrl = reflectedRepoUrl, + PackageId = reflectedPackageId, + Version = reflectedVersion, + }); + } + } + + // Configured but not currently loaded - pass through unchanged + mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p))); + + result.Packages = mergedPackages; + } + + private static string GetAssemblyMetadataValue(Assembly assembly, string key) + { + if (assembly == null) + { + return null; + } + + var match = assembly.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false) + .Cast() + .FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase)); + + return match?.Value; + } + + private static string GetAssemblyProduct(Assembly assembly) + { + if (assembly == null) + { + return null; + } + + var attribute = assembly.GetCustomAttributes(typeof(AssemblyProductAttribute), false) + .FirstOrDefault() as AssemblyProductAttribute; + + return attribute?.Product; + } + + private static string GetAssemblyTitle(Assembly assembly) + { + if (assembly == null) + { + return null; + } + + var attribute = assembly.GetCustomAttributes(typeof(AssemblyTitleAttribute), false) + .FirstOrDefault() as AssemblyTitleAttribute; + + return attribute?.Title; + } + + private static string StripTrailingSeriesSuffix(string assemblyName) + { + const string suffix = ".4Series"; + + if (string.IsNullOrEmpty(assemblyName) || !assemblyName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return assemblyName; + } + + return assemblyName.Substring(0, assemblyName.Length - suffix.Length); + } + + private static string TrimTrailingGit(string repoUrl) + { + const string suffix = ".git"; + + if (string.IsNullOrEmpty(repoUrl) || !repoUrl.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return repoUrl; + } + + return repoUrl.Substring(0, repoUrl.Length - suffix.Length); + } + } +} diff --git a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs index 80c1f8ee..d542f52a 100644 --- a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs +++ b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftController.cs @@ -152,6 +152,15 @@ namespace PepperDash.Essentials.Devices.Common.Shades private void IsCoolingDownFeedback_OutputChange(object sender, FeedbackEventArgs e) { + if (Config.DisableAutoRaiseOnPowerOff) + { + this.LogDebug( + "Auto-raise on power-off disabled for {type}; leaving position unchanged (manual control only)", + Type + ); + return; + } + if ( !DisplayDevice.IsCoolingDownFeedback.BoolValue && Type == eScreenLiftControlType.lift @@ -174,6 +183,15 @@ namespace PepperDash.Essentials.Devices.Common.Shades { if (DisplayDevice.IsWarmingUpFeedback.BoolValue) { + if (Config.DisableAutoLowerOnPowerOn) + { + this.LogDebug( + "Auto-lower on power-on disabled for {type}; leaving position unchanged (manual control only)", + Type + ); + return; + } + Lower(); } } diff --git a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs index 1c4f9906..88113a08 100644 --- a/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs +++ b/src/PepperDash.Essentials.Devices.Common/Displays/ScreenLiftControllerConfigProperties.cs @@ -41,5 +41,23 @@ namespace PepperDash.Essentials.Devices.Common.Shades /// [JsonProperty("muteOnScreenUp")] public bool MuteOnScreenUp { get; set; } + + /// + /// When true, this controller does NOT automatically lower when its assigned display powers on + /// (warms up). Manual Raise/Lower still work, and the power-off auto-raise is unaffected. Intended + /// for a projector screen that must not auto-drop in a public space for safety, while the projector + /// lift (a separate controller) can still drop automatically. + /// + [JsonProperty("disableAutoLowerOnPowerOn")] + public bool DisableAutoLowerOnPowerOn { get; set; } + + /// + /// When true, this controller does NOT automatically raise when its assigned display powers off + /// (cools down). Manual Raise/Lower still work, and the power-on auto-lower is unaffected. The + /// companion to ; together they make a controller fully + /// manual while leaving other controllers (e.g. the lift) on their default automatic behavior. + /// + [JsonProperty("disableAutoRaiseOnPowerOff")] + public bool DisableAutoRaiseOnPowerOff { get; set; } } } diff --git a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj index 7be4372d..317fa2d3 100644 --- a/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj +++ b/src/PepperDash.Essentials.Devices.Common/PepperDash.Essentials.Devices.Common.csproj @@ -29,6 +29,6 @@ - + \ No newline at end of file diff --git a/src/PepperDash.Essentials.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..c5c0ab65 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 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; } /// diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj index d13d1a09..a9223061 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj +++ b/src/PepperDash.Essentials.MobileControl.Messengers/PepperDash.Essentials.MobileControl.Messengers.csproj @@ -33,7 +33,7 @@ - + diff --git a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs new file mode 100644 index 00000000..68f5e36e --- /dev/null +++ b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs @@ -0,0 +1,61 @@ +using System; +using Newtonsoft.Json; + +namespace PepperDash.Essentials +{ + /// + /// Represents the version information reported by a connected Mobile Control UI client + /// + public class ConnectedClientVersionInfo + { + /// + /// Gets or sets the client id + /// + [JsonProperty("clientId")] + public string ClientId { get; set; } + + /// + /// Gets or sets the room key the client joined + /// + [JsonProperty("roomKey")] + public string RoomKey { get; set; } + + /// + /// Gets or sets the touchpanel key the client joined as, if any + /// + [JsonProperty("touchpanelKey")] + public string TouchpanelKey { get; set; } + + /// + /// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION) + /// + [JsonProperty("appVersion")] + public string AppVersion { get; set; } + + /// + /// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured + /// + [JsonProperty("expectedAppVersion")] + public string ExpectedAppVersion { get; set; } + + /// + /// Gets or sets the UTC time the client last reported this version + /// + [JsonProperty("lastSeen")] + public DateTime LastSeen { get; set; } + + /// + /// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate + /// without affecting the internally tracked instance + /// + public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo + { + ClientId = ClientId, + RoomKey = RoomKey, + TouchpanelKey = TouchpanelKey, + AppVersion = AppVersion, + ExpectedAppVersion = ExpectedAppVersion, + LastSeen = LastSeen + }; + } +} diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index 3d466f5b..c17cf3b9 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -69,11 +69,32 @@ namespace PepperDash.Essentials private readonly Dictionary _defaultMessengers = new Dictionary(); + private readonly Dictionary _connectedClientVersions = + new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + private readonly object _connectedClientVersionsLock = new object(); + /// /// Get the custom messengers with subscriptions /// public ReadOnlyDictionary Messengers => new ReadOnlyDictionary(_messengers.Values.OfType().ToDictionary(k => k.Key, v => v)); + /// + /// Gets the most recently reported UI app version for each connected client, keyed by clientId + /// + public ReadOnlyDictionary ConnectedClientVersions + { + get + { + lock (_connectedClientVersionsLock) + { + return new ReadOnlyDictionary( + _connectedClientVersions.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()) + ); + } + } + } + /// /// Get the default messengers /// @@ -1782,6 +1803,28 @@ namespace PepperDash.Essentials " Not Enabled in Config.\r\n" ); } + + var connectedClientVersions = ConnectedClientVersions; + + if (connectedClientVersions.Count == 0) + { + CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions: None reported yet\r\n"); + } + else + { + CrestronConsole.ConsoleCommandResponse("\r\nUI Client App Versions:\r\n"); + foreach (var kv in connectedClientVersions) + { + var v = kv.Value; + var match = string.IsNullOrEmpty(v.ExpectedAppVersion) || string.Equals(v.ExpectedAppVersion, v.AppVersion, StringComparison.OrdinalIgnoreCase); + + CrestronConsole.ConsoleCommandResponse( + $" Client: {v.ClientId} Touchpanel: {v.TouchpanelKey} Room: {v.RoomKey}\r\n" + + $" Reported: {v.AppVersion} Expected: {(string.IsNullOrEmpty(v.ExpectedAppVersion) ? "(not configured)" : v.ExpectedAppVersion)} Match: {(match ? "Yes" : "NO - MISMATCH")}\r\n" + + $" Last Seen (UTC): {v.LastSeen:yyyy-MM-dd HH:mm:ss}\r\n" + ); + } + } } /// @@ -2181,6 +2224,8 @@ namespace PepperDash.Essentials var roomKey = content["roomKey"].Value(); var touchpanelKey = content.SelectToken("touchpanelKey"); + TrackClientAppVersion(clientId, roomKey, touchpanelKey?.Value(), content.SelectToken("appVersion")?.Value()); + if (_roomCombiner == null) { var message = new MobileControlMessage @@ -2252,6 +2297,50 @@ namespace PepperDash.Essentials SendTouchpanelKey(clientId, touchpanelKey); } + /// + /// Records the app version reported by a connecting UI client (e.g. the mobile control React app's + /// build-time APP_VERSION) and compares it against the configured versions.touchpanelWrapperApp version. + /// + private void TrackClientAppVersion(string clientId, string roomKey, string touchpanelKey, string appVersion) + { + if (string.IsNullOrEmpty(appVersion)) + { + return; + } + + var expectedVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version; + + var info = new ConnectedClientVersionInfo + { + ClientId = clientId, + RoomKey = roomKey, + TouchpanelKey = touchpanelKey, + AppVersion = appVersion, + ExpectedAppVersion = expectedVersion, + LastSeen = DateTime.UtcNow + }; + + lock (_connectedClientVersionsLock) + { + _connectedClientVersions[clientId] = info; + } + + if (!string.IsNullOrEmpty(expectedVersion) && !string.Equals(expectedVersion, appVersion, StringComparison.OrdinalIgnoreCase)) + { + this.LogWarning( + "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}, which does not match configured versions.touchpanelWrapperApp version {expectedVersion}", + clientId, touchpanelKey, appVersion, expectedVersion + ); + } + else + { + this.LogVerbose( + "Client {clientId} (touchpanel {touchpanelKey}) reported UI app version {appVersion}", + clientId, touchpanelKey, appVersion + ); + } + } + private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken) { if (touchpanelKeyToken == null) diff --git a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj index 235e0899..b0e2dd9f 100644 --- a/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj +++ b/src/PepperDash.Essentials.MobileControl/PepperDash.Essentials.MobileControl.csproj @@ -38,7 +38,7 @@ - + diff --git a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs index 5830782d..262cb34e 100644 --- a/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs +++ b/src/PepperDash.Essentials.MobileControl/Touchpanel/MobileControlTouchpanelController.cs @@ -25,11 +25,28 @@ namespace PepperDash.Essentials.Touchpanel /// Mobile Control touchpanel controller that provides app control, Zoom integration, /// and mobile control functionality for Crestron touchpanels. /// - public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme + public class MobileControlTouchpanelController : TouchpanelBase, IHasFeedback, ITswAppControl, ITswZoomControl, IDeviceInfoProvider, IMobileControlCrestronTouchpanelController, ITheme, ICommunicationMonitor { private readonly MobileControlTouchpanelProperties localConfig; private IMobileControlRoomMessenger _bridge; + /// + /// Gets the CommunicationMonitor tracking the panel's online/offline state + /// + public StatusMonitorBase CommunicationMonitor { get; private set; } + + private sealed class NullCommunicationMonitor : StatusMonitorBase + { + public NullCommunicationMonitor(IKeyed parent) : base(parent, 120000, 300000) + { + Status = MonitorStatus.InError; + Message = "Panel is not initialized"; + } + + public override void Start() { } + public override void Stop() { } + } + private string _appUrl; /// @@ -128,6 +145,11 @@ namespace PepperDash.Essentials.Touchpanel { localConfig = config; + if (panel != null) + { + CommunicationMonitor = new CrestronGenericBaseCommunicationMonitor(this, panel, 120000, 300000); + } + AddPostActivationAction(SubscribeForMobileControlUpdates); ThemeFeedback = new StringFeedback($"{Key}-theme", () => Theme); @@ -366,6 +388,8 @@ namespace PepperDash.Essentials.Touchpanel /// public override bool CustomActivate() { + CommunicationMonitor?.Start(); + var appMessenger = new ITswAppControlMessenger($"appControlMessenger-{Key}", $"/device/{Key}", this); var zoomMessenger = new ITswZoomControlMessenger($"zoomControlMessenger-{Key}", $"/device/{Key}", this); @@ -393,6 +417,17 @@ namespace PepperDash.Essentials.Touchpanel return base.CustomActivate(); } + /// + /// Stops the CommunicationMonitor on deactivation. + /// + /// True if deactivation was successful; otherwise, false. + public override bool Deactivate() + { + CommunicationMonitor?.Stop(); + + return base.Deactivate(); + } + /// /// Handles device extender signal changes for system reserved signals. /// @@ -519,15 +554,8 @@ namespace PepperDash.Essentials.Touchpanel return false; }) ? csIpAddress.ToString() : processorIp; - var match = Regex.Match(url, @"^http://([^:/]+):\d+/mc/app\?token=.+$"); - if (match.Success) - { - string ipa = match.Groups[1].Value; - // ip will be "192.168.1.100" - } - - // replace ipa with ip but leave the rest of the string intact - var updatedUrl = Regex.Replace(url, @"^http://[^:/]+", $"http://{ip}"); + // replace the host but preserve whatever scheme (http/https) is already present in the URL + var updatedUrl = Regex.Replace(url, @"^(https?)://[^:/]+", $"$1://{ip}"); this.LogVerbose("Updated URL: {updatedUrl}", updatedUrl); @@ -740,7 +768,7 @@ namespace PepperDash.Essentials.Touchpanel /// public MobileControlTouchpanelControllerFactory() { - TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mcxpanel", "mcdge1000" }; + TypeNames = new List() { "mccrestronapp", "mctsw550", "mctsw750", "mctsw1050", "mctsw560", "mctsw760", "mctsw1060", "mctsw570", "mctsw770", "mcts770", "mctsw1070", "mcts1070", "mctsw1080", "mcts1080", "mcxpanel", "mcdge1000" }; MinimumEssentialsFrameworkVersion = "2.0.0"; factories = new Dictionary> @@ -765,6 +793,8 @@ namespace PepperDash.Essentials.Touchpanel {"ts770", (id, controlSystem, projectName) => new Ts770(id, controlSystem)}, {"tsw1070", (id, controlSystem, projectName) => new Tsw1070(id, controlSystem)}, {"ts1070", (id, controlSystem, projectName) => new Ts1070(id, controlSystem)}, + {"tsw1080", (id, controlSystem, projectName) => new Tsw1080(id, controlSystem)}, + {"ts1080", (id, controlSystem, projectName) => new Ts1080(id, controlSystem)}, {"dge1000", (id, controlSystem, projectName) => new Dge1000(id, controlSystem)} }; } diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index 1c9ed37a..c96dc116 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -127,6 +127,16 @@ namespace PepperDash.Essentials.WebSocketServer /// public int Port { get; private set; } + /// + /// Gets the HTTP scheme to use for generated URLs, based on whether the direct server is configured as secure + /// + private string HttpScheme => _parent.Config.DirectServer.Secure ? "https" : "http"; + + /// + /// Gets the WebSocket scheme to use for generated URLs, based on whether the direct server is configured as secure + /// + private string WsScheme => _parent.Config.DirectServer.Secure ? "wss" : "ws"; + /// /// Gets the user app URL prefix /// @@ -134,7 +144,8 @@ namespace PepperDash.Essentials.WebSocketServer { get { - return string.Format("http://{0}:{1}{2}?token=", + return string.Format("{0}://{1}:{2}{3}?token=", + HttpScheme, CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), Port, _userAppBaseHref); @@ -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}:{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, Port), GatewayAppPath = "", LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png", EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false, @@ -1098,6 +1109,7 @@ namespace PepperDash.Essentials.WebSocketServer res.StatusCode = 200; res.Close(); + // remote log collector has no dedicated secure flag; keep it on http regardless of DirectServer.Secure var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs") { Content = new StringContent(body, Encoding.UTF8, "application/json"), @@ -1150,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; @@ -1213,8 +1230,7 @@ namespace PepperDash.Essentials.WebSocketServer this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now); // Construct WebSocket URL with clientId query parameter - var wsProtocol = "ws"; - var wsUrl = $"{wsProtocol}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}"; + var wsUrl = $"{WsScheme}://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{Port}{_wsPath}{token}?clientId={clientId}"; // Construct the response object JoinResponse jRes = new JoinResponse @@ -1226,7 +1242,8 @@ namespace PepperDash.Essentials.WebSocketServer Config = _parent.GetConfigWithPluginVersion(), CodeExpires = new DateTime().AddYears(1), UserCode = bridge.UserCode, - UserAppUrl = string.Format("http://{0}:{1}/mc/app", + UserAppUrl = string.Format("{0}://{1}:{2}/mc/app", + HttpScheme, CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0), Port), WebSocketUrl = wsUrl, @@ -1251,6 +1268,8 @@ namespace PepperDash.Essentials.WebSocketServer { res.StatusCode = 200; res.ContentType = "application/json"; + res.Headers.Add("Cache-Control", "no-store"); + res.Headers.Add("Pragma", "no-cache"); var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion }; var message = JsonConvert.SerializeObject(version); this.LogVerbose("{message}", message); diff --git a/src/PepperDash.Essentials/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 @@ - +