diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index ef8e86b0..bdc59cb9 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -5,7 +5,7 @@
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 a1afba93..0dd39fee 100644
--- a/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs
+++ b/src/PepperDash.Essentials.Core/Config/Essentials/ConfigReader.cs
@@ -132,12 +132,14 @@ namespace PepperDash.Essentials.Core.Config;
{
var parsedConfig = JObject.Parse(fileContents);
- // 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;
+ // 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 (isV2Config)
+ if (!isV1Config)
{
Debug.LogMessage(LogEventLevel.Information, "Config file is a v2 format, no merge necessary.");
ConfigObject = parsedConfig.ToObject();
@@ -145,6 +147,8 @@ namespace PepperDash.Essentials.Core.Config;
return ConfigObject != null;
}
+ 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();
@@ -163,6 +167,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 0fb0c93e..bcfd6227 100644
--- a/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs
+++ b/src/PepperDash.Essentials.Core/Config/Essentials/EssentialsConfig.cs
@@ -16,9 +16,15 @@ namespace PepperDash.Essentials.Core.Config;
///
public class EssentialsConfig : BasicConfig
{
+ ///
+ /// Gets or sets the System URL for the Essentials configuration
+ ///
[JsonProperty("system_url")]
public string SystemUrl { get; set; }
+ ///
+ /// Gets or sets the Template URL for the Essentials configuration
+ ///
[JsonProperty("template_url")]
public string TemplateUrl { get; set; }
@@ -74,14 +80,21 @@ public class EssentialsConfig : BasicConfig
}
}
+ ///
+ /// Gets or sets the list of rooms (device configurations)
+ ///
[JsonProperty("rooms")]
public List Rooms { get; set; }
///
/// Gets or sets the Versions
///
+ [JsonProperty("versions")]
public VersionData Versions { get; set; }
+ ///
+ /// Initializes a new instance of the class.
+ ///
public EssentialsConfig()
: base()
{
@@ -98,13 +111,13 @@ public class VersionData
/// Gets or sets the Essentials version
///
[JsonProperty("essentials")]
- public NugetVersion Essentials { get; set; }
+ public PackageVersion Essentials { get; set; }
///
/// Gets or sets the list of UserInterfaces
///
[JsonProperty("userInterfaces")]
- public List UserInterfaces { get; set; }
+ public List UserInterfaces { get; set; }
///
/// Gets or sets the TouchpanelWrapperApp version
@@ -112,45 +125,29 @@ public class VersionData
/// to run an HTML5 user interface.
///
[JsonProperty("touchpanelWrapperApp")]
- public AppVersion TouchpanelWrapperApp { get; set; }
+ public PackageVersion TouchpanelWrapperApp { get; set; }
///
/// Gets or sets the list of Packages
///
[JsonProperty("packages")]
- public List Packages { get; set; }
+ public List Packages { get; set; }
///
/// Initializes a new instance of the class.
///
public VersionData()
{
- UserInterfaces = new List();
- TouchpanelWrapperApp = new AppVersion();
- Packages = new List();
+ UserInterfaces = new List();
+ TouchpanelWrapperApp = new PackageVersion();
+ Packages = new List();
}
}
-public class AppVersion
-{
- ///
- /// Gets or sets the Version
- ///
- [JsonProperty("version")]
- public string Version { get; set; }
-
- ///
- /// Gets or sets the RepoUrl
- ///
- [JsonProperty("repoUrl")]
- public string RepoUrl { get; set; }
-}
-
-
///
-/// Represents a NugetVersion
+/// Represents a Package Version
///
-public class NugetVersion
+public class PackageVersion
{
///
/// Gets or sets the Version
@@ -161,14 +158,20 @@ public class NugetVersion
///
/// Gets or sets the PackageId
///
- [JsonProperty("packageId")]
+ [JsonProperty("packageId", NullValueHandling = NullValueHandling.Ignore)]
public string PackageId { get; set; }
///
/// Gets or sets the RepoUrl
///
- [JsonProperty("repoUrl")]
+ [JsonProperty("repoUrl", NullValueHandling = NullValueHandling.Ignore)]
public string RepoUrl { get; set; }
+
+ ///
+ /// Gets or sets the human-readable name
+ ///
+ [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)]
+ public string Name { get; set; }
}
///
@@ -176,6 +179,9 @@ public class NugetVersion
///
public class SystemTemplateConfigs
{
+ ///
+ /// Gets or sets the System configuration
+ ///
public EssentialsConfig System { get; set; }
///
diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs
index 86f01803..9c16d47e 100644
--- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs
+++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs
@@ -90,6 +90,11 @@ public class EssentialsWebApi : EssentialsDevice
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..f89b0919
--- /dev/null
+++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetPackageManifestRequestHandler.cs
@@ -0,0 +1,261 @@
+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 PackageVersion();
+
+ 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 PackageVersion
+ {
+ 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 PackageVersion
+ {
+ Name = reflectedName,
+ RepoUrl = reflectedRepoUrl,
+ PackageId = reflectedPackageId,
+ Version = reflectedVersion,
+ });
+ }
+ }
+
+ // Configured but not currently loaded - pass through unchanged, but keep the same
+ // "never emit an entry with no version" guarantee as the loaded-reflection branch above -
+ // otherwise this branch reintroduces the schema issue that guard is meant to prevent.
+ mergedPackages.AddRange(configPackages.Where(p =>
+ !matchedConfigPackages.Contains(p) && !string.IsNullOrEmpty(p.Version)));
+
+ 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/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs
index f5c5f096..ea3b02d1 100644
--- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs
+++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs
@@ -1006,6 +1006,14 @@ namespace PepperDash.Essentials
" Not Enabled in Config.\r\n"
);
}
+
+ var expectedAppVersion = ConfigReader.ConfigObject?.Versions?.TouchpanelWrapperApp?.Version;
+ var userAppPath = Global.FilePathPrefix + "mcUserApp" + Global.DirectorySeparator;
+ var versionCheck = TouchpanelWrapperAppVersionChecker.CheckDeployedVersion(userAppPath, expectedAppVersion);
+
+ CrestronConsole.ConsoleCommandResponse(
+ $"\r\nUI Wrapper App Deployed Version Check:\r\n {versionCheck.Summary}\r\n"
+ );
}
///
diff --git a/src/PepperDash.Essentials.MobileControl/TouchpanelWrapperAppVersionChecker.cs b/src/PepperDash.Essentials.MobileControl/TouchpanelWrapperAppVersionChecker.cs
new file mode 100644
index 00000000..1368b788
--- /dev/null
+++ b/src/PepperDash.Essentials.MobileControl/TouchpanelWrapperAppVersionChecker.cs
@@ -0,0 +1,141 @@
+using System;
+using System.IO;
+using System.Linq;
+
+namespace PepperDash.Essentials
+{
+ ///
+ /// Determines whether the touchpanel wrapper app (the mobile control React app) deployed to this
+ /// processor's mcUserApp folder matches the version configured in the system config's
+ /// versions.touchpanelWrapperApp.
+ ///
+ ///
+ /// This reads the app's built .js/.html files directly from disk, so unlike the previous
+ /// client-self-reported-version approach, it does not depend on any UI client being connected and
+ /// does not accumulate per-client state over the life of the program.
+ ///
+ public static class TouchpanelWrapperAppVersionChecker
+ {
+ private static readonly string[] SearchPatterns = { "*.js", "*.html" };
+
+ ///
+ /// Scans the deployed touchpanel wrapper app's .js/.html files under
+ /// for the literal string (the version build tooling bakes
+ /// into the app at build time).
+ ///
+ /// The path to the deployed mcUserApp folder
+ /// The expected version, from config's versions.touchpanelWrapperApp.version
+ public static TouchpanelWrapperAppVersionCheckResult CheckDeployedVersion(string appPath, string expectedVersion)
+ {
+ if (string.IsNullOrEmpty(expectedVersion))
+ {
+ return TouchpanelWrapperAppVersionCheckResult.NotConfigured(appPath);
+ }
+
+ if (string.IsNullOrEmpty(appPath) || !Directory.Exists(appPath))
+ {
+ return TouchpanelWrapperAppVersionCheckResult.AppNotDeployed(appPath, expectedVersion);
+ }
+
+ try
+ {
+ var files = SearchPatterns
+ .SelectMany(pattern => Directory.GetFiles(appPath, pattern, SearchOption.AllDirectories))
+ .ToList();
+
+ if (files.Count == 0)
+ {
+ return TouchpanelWrapperAppVersionCheckResult.AppNotDeployed(appPath, expectedVersion);
+ }
+
+ foreach (var file in files)
+ {
+ var contents = File.ReadAllText(file);
+ if (contents.IndexOf(expectedVersion, StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ return TouchpanelWrapperAppVersionCheckResult.Match(appPath, expectedVersion, file);
+ }
+ }
+
+ return TouchpanelWrapperAppVersionCheckResult.Mismatch(appPath, expectedVersion, files.Count);
+ }
+ catch (Exception ex)
+ {
+ return TouchpanelWrapperAppVersionCheckResult.Error(appPath, expectedVersion, ex.Message);
+ }
+ }
+ }
+
+ ///
+ /// The outcome of a check
+ ///
+ public class TouchpanelWrapperAppVersionCheckResult
+ {
+ ///
+ /// The mcUserApp path that was checked
+ ///
+ public string AppPath { get; }
+
+ ///
+ /// The expected version from config, if any was configured
+ ///
+ public string ExpectedVersion { get; }
+
+ ///
+ /// True if any files were found deployed at
+ ///
+ public bool AppDeployed { get; }
+
+ ///
+ /// True if was found in one of the deployed files
+ ///
+ public bool VersionMatched { get; }
+
+ ///
+ /// The file the expected version was found in, if is true
+ ///
+ public string MatchedFile { get; }
+
+ ///
+ /// The number of files scanned
+ ///
+ public int FilesScanned { get; }
+
+ ///
+ /// A human-readable summary of the outcome, suitable for console output
+ ///
+ public string Summary { get; }
+
+ private TouchpanelWrapperAppVersionCheckResult(string appPath, string expectedVersion, bool appDeployed,
+ bool versionMatched, string matchedFile, int filesScanned, string summary)
+ {
+ AppPath = appPath;
+ ExpectedVersion = expectedVersion;
+ AppDeployed = appDeployed;
+ VersionMatched = versionMatched;
+ MatchedFile = matchedFile;
+ FilesScanned = filesScanned;
+ Summary = summary;
+ }
+
+ internal static TouchpanelWrapperAppVersionCheckResult NotConfigured(string appPath) =>
+ new TouchpanelWrapperAppVersionCheckResult(appPath, null, false, false, null, 0,
+ "versions.touchpanelWrapperApp.version is not configured; skipping check");
+
+ internal static TouchpanelWrapperAppVersionCheckResult AppNotDeployed(string appPath, string expectedVersion) =>
+ new TouchpanelWrapperAppVersionCheckResult(appPath, expectedVersion, false, false, null, 0,
+ $"No app files found at '{appPath}'; expected version {expectedVersion}");
+
+ internal static TouchpanelWrapperAppVersionCheckResult Match(string appPath, string expectedVersion, string matchedFile) =>
+ new TouchpanelWrapperAppVersionCheckResult(appPath, expectedVersion, true, true, matchedFile, 1,
+ $"Deployed app matches configured version {expectedVersion} (found in '{matchedFile}')");
+
+ internal static TouchpanelWrapperAppVersionCheckResult Mismatch(string appPath, string expectedVersion, int filesScanned) =>
+ new TouchpanelWrapperAppVersionCheckResult(appPath, expectedVersion, true, false, null, filesScanned,
+ $"Deployed app at '{appPath}' does NOT contain expected version {expectedVersion} ({filesScanned} file(s) scanned) - MISMATCH");
+
+ internal static TouchpanelWrapperAppVersionCheckResult Error(string appPath, string expectedVersion, string errorMessage) =>
+ new TouchpanelWrapperAppVersionCheckResult(appPath, expectedVersion, false, false, null, 0,
+ $"Error checking deployed app version at '{appPath}': {errorMessage}");
+ }
+}