diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 7435df6f..ab9f2732 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
@@ -20,4 +20,7 @@
+
+
+
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/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/IMobileControlMessengerWithSubscriptions.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs
index 887f1789..8603d5b6 100644
--- a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs
+++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs
@@ -1,10 +1,12 @@
+using System;
using PepperDash.Core;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
///
- /// Defines the contract for IMobileControlMessenger
+ /// Obsolete: messengers are subscription based by default; use IMobileControlMessenger instead.
///
+ [Obsolete("This interface is obsolete and will be removed in a future version. All messengers are now subscription based.")]
public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger
{
///
diff --git a/src/PepperDash.Essentials.Core/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/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)
{
diff --git a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs
index 382f5d55..ac4e80d8 100644
--- a/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs
+++ b/src/PepperDash.Essentials.Core/Secrets/SecretsManager.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Crestron.SimplSharp;
using PepperDash.Core;
+using PepperDash.Core.Logging;
using Serilog.Events;
namespace PepperDash.Essentials.Core
@@ -296,19 +297,14 @@ namespace PepperDash.Essentials.Core
{
var secretPresent = provider.TestSecret(key);
- Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
+ provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
if (!secretPresent)
return
- String.Format(
- "Unable to update secret for {0}:{1} - Please use the 'SetSecret' command to modify it");
+ $"Unable to update secret for {provider.Key}:{key} - Please use the 'SetSecret' command to modify it";
var response = provider.SetSecret(key, secret)
- ? String.Format(
- "Secret successfully set for {0}:{1}",
- provider.Key, key)
- : String.Format(
- "Unable to set secret for {0}:{1}",
- provider.Key, key);
+ ? $"Secret successfully set for {provider.Key}:{key}"
+ : $"Unable to set secret for {provider.Key}:{key}";
return response;
}
@@ -316,19 +312,14 @@ namespace PepperDash.Essentials.Core
{
var secretPresent = provider.TestSecret(key);
- Debug.LogMessage(LogEventLevel.Verbose, provider, "SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
+ provider.LogVerbose("SecretsProvider {0} {1} contain a secret entry for {2}", provider.Key, secretPresent ? "does" : "does not", key);
if (secretPresent)
return
- String.Format(
- "Unable to set secret for {0}:{1} - Please use the 'UpdateSecret' command to modify it");
+ $"Unable to set secret for {provider.Key}:{key} - Please use the 'UpdateSecret' command to modify it";
var response = provider.SetSecret(key, secret)
- ? String.Format(
- "Secret successfully set for {0}:{1}",
- provider.Key, key)
- : String.Format(
- "Unable to set secret for {0}:{1}",
- provider.Key, key);
+ ? $"Secret successfully set for {provider.Key}:{key}"
+ : $"Unable to set secret for {provider.Key}:{key}";
return response;
}
@@ -377,15 +368,10 @@ namespace PepperDash.Essentials.Core
var key = args[1];
-
provider.SetSecret(key, "");
response = provider.SetSecret(key, "")
- ? String.Format(
- "Secret successfully deleted for {0}:{1}",
- provider.Key, key)
- : String.Format(
- "Unable to delete secret for {0}:{1}",
- provider.Key, key);
+ ? $"Secret successfully deleted for {provider.Key}:{key}"
+ : $"Unable to delete secret for {provider.Key}:{key}";
CrestronConsole.ConsoleCommandResponse(response);
return;
diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs
index 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/DebugSessionRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs
index 56c983ae..59c662dc 100644
--- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs
+++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DebugSessionRequestHandler.cs
@@ -17,7 +17,10 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
/// Represents a DebugSessionRequestHandler
///
public class DebugSessionRequestHandler : WebApiBaseRequestHandler
- {
+ {
+ private CTimer _portForwardTimeoutTimer;
+ private readonly object _timerLock = new object();
+
///
/// Constructor
///
@@ -48,6 +51,7 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0);
var port = 0;
+ string csIp = null;
if (!Debug.WebsocketSink.IsRunning)
{
@@ -57,15 +61,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);
- 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);
@@ -77,26 +84,29 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
else
{
Debug.LogMessage(LogEventLevel.Information, "Port {0} forwarded to CS LAN for debug websocket", port);
+ StartPortForwardTimeout(port, csIp);
}
}
- catch (ArgumentException)
- {
- Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding");
- }
- catch (Exception ex)
- {
- Debug.LogMessage(LogEventLevel.Warning, "Error automatically forwarding debug websocket port to CS LAN: {0}", ex.Message);
- }
+ }
+ catch (ArgumentException)
+ {
+ Debug.LogMessage(LogEventLevel.Debug, "This processor does not have a CS LAN adapter; skipping port forwarding");
+ }
+ catch (Exception ex)
+ {
+ Debug.LogMessage(LogEventLevel.Warning, "Error automatically forwarding debug websocket port to CS LAN: {0}", ex.Message);
}
var url = Debug.WebsocketSink.Url;
- object data = new
+ var data = new
{
- url = Debug.WebsocketSink.Url
+ url = Debug.WebsocketSink.Url,
+ fallbackUrl = csIp != null ? url.Replace(csIp, ip) : null
};
Debug.LogMessage(LogEventLevel.Information, "Debug Session URL: {0}", url);
+ Debug.LogMessage(LogEventLevel.Information, "Fallback Debug Session URL: {0}", data.fallbackUrl);
// Return the port number with the full url of the WS Server
var res = JsonConvert.SerializeObject(data);
@@ -120,6 +130,8 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
///
protected override void HandlePost(HttpCwsContext context)
{
+ CancelPortForwardTimeout();
+
var port = Debug.WebsocketSink.Port;
Debug.WebsocketSink.StopServer();
@@ -132,17 +144,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)
@@ -161,5 +180,55 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
Debug.LogMessage(LogEventLevel.Information, "Websocket Debug Session Stopped");
}
+ private void StartPortForwardTimeout(int port, string csIp)
+ {
+ lock (_timerLock)
+ {
+ _portForwardTimeoutTimer?.Dispose();
+ _portForwardTimeoutTimer = new CTimer(_ =>
+ {
+ if (Debug.WebsocketSink.HasActiveConnections)
+ {
+ Debug.LogMessage(LogEventLevel.Debug, "Debug websocket has active connections; keeping port forward");
+ return;
+ }
+
+ Debug.LogMessage(LogEventLevel.Information, "No debug websocket connection within 30 seconds; removing port forward for port {0}", port);
+
+ try
+ {
+ var result = CrestronEthernetHelper.RemovePortForwarding(
+ (ushort)port, (ushort)port, csIp,
+ CrestronEthernetHelper.ePortMapTransport.TCP);
+
+ if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
+ {
+ Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", result);
+ }
+ else
+ {
+ Debug.LogMessage(LogEventLevel.Information, "Port forwarding for port {0} removed due to timeout", port);
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.LogMessage(LogEventLevel.Warning, "Error removing port forwarding on timeout: {0}", ex.Message);
+ }
+ }, 30000);
+ }
+ }
+
+ ///
+ /// Cancels the port forward timeout timer if a session is being explicitly stopped.
+ ///
+ private void CancelPortForwardTimeout()
+ {
+ lock (_timerLock)
+ {
+ _portForwardTimeoutTimer?.Dispose();
+ _portForwardTimeoutTimer = null;
+ }
+ }
+
}
}
diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs
index 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
[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; }
///
diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs
index 3031f4ba..eb3afec3 100644
--- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs
+++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs
@@ -264,6 +264,9 @@ namespace PepperDash.Essentials.AppServer.Messengers
message.Name = _device.Name;
+ message.MessageBasePath = MessagePath;
+
+
var token = JToken.FromObject(message);
PostStatusMessage(token, MessagePath, clientId);
diff --git a/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs
new file mode 100644
index 00000000..68f5e36e
--- /dev/null
+++ b/src/PepperDash.Essentials.MobileControl/ConnectedClientVersionInfo.cs
@@ -0,0 +1,61 @@
+using System;
+using Newtonsoft.Json;
+
+namespace PepperDash.Essentials
+{
+ ///
+ /// Represents the version information reported by a connected Mobile Control UI client
+ ///
+ public class ConnectedClientVersionInfo
+ {
+ ///
+ /// Gets or sets the client id
+ ///
+ [JsonProperty("clientId")]
+ public string ClientId { get; set; }
+
+ ///
+ /// Gets or sets the room key the client joined
+ ///
+ [JsonProperty("roomKey")]
+ public string RoomKey { get; set; }
+
+ ///
+ /// Gets or sets the touchpanel key the client joined as, if any
+ ///
+ [JsonProperty("touchpanelKey")]
+ public string TouchpanelKey { get; set; }
+
+ ///
+ /// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION)
+ ///
+ [JsonProperty("appVersion")]
+ public string AppVersion { get; set; }
+
+ ///
+ /// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured
+ ///
+ [JsonProperty("expectedAppVersion")]
+ public string ExpectedAppVersion { get; set; }
+
+ ///
+ /// Gets or sets the UTC time the client last reported this version
+ ///
+ [JsonProperty("lastSeen")]
+ public DateTime LastSeen { get; set; }
+
+ ///
+ /// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate
+ /// without affecting the internally tracked instance
+ ///
+ public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo
+ {
+ ClientId = ClientId,
+ RoomKey = RoomKey,
+ TouchpanelKey = TouchpanelKey,
+ AppVersion = AppVersion,
+ ExpectedAppVersion = ExpectedAppVersion,
+ LastSeen = LastSeen
+ };
+ }
+}
diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs
index ec7219a3..963e7fd5 100644
--- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs
+++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
@@ -40,9 +41,10 @@ namespace PepperDash.Essentials
public bool EnableApiServer { get; set; } = true;
///
- /// Enable subscriptions for Messengers
+ /// Enables subscriptions for messengers
///
[JsonProperty("enableMessengerSubscriptions")]
+ [Obsolete("This property is obsolete and will be removed in a future version. All messengers are now subscription based.")]
public bool EnableMessengerSubscriptions { get; set; }
}
@@ -288,4 +290,4 @@ namespace PepperDash.Essentials
///
NEO
}
-}
\ No newline at end of file
+}
diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs
index 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)