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.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/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..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.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)