Merge pull request #1439 from PepperDash/feature/version-data-touchpanel-ui-schema

fix(config): version data, packageManifest API, and v1/v2 config detection
This commit is contained in:
Jason DeVito 2026-07-08 10:25:55 -05:00 committed by GitHub
commit 6ceddcf571
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 463 additions and 9 deletions

View file

@ -1,11 +1,11 @@
<Project>
<PropertyGroup>
<Version>2.29.0-local</Version>
<Version>2.36.6-local</Version>
<InformationalVersion>$(Version)</InformationalVersion>
<Authors>PepperDash Technology</Authors>
<Company>PepperDash Technology</Company>
<Product>PepperDash Essentials</Product>
<Copyright>Copyright © 2025</Copyright>
<Copyright>Copyright © 2026</Copyright>
<RepositoryUrl>https://github.com/PepperDash/Essentials</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>Crestron; 4series</PackageTags>
@ -20,4 +20,7 @@
<None Include="..\..\LICENSE.md" Pack="true" PackagePath=""/>
<None Include="..\..\README.md" Pack="true" PackagePath=""/>
</ItemGroup>
<ItemGroup>
<AssemblyMetadata Include="PackageId" Value="$(PackageId)" />
</ItemGroup>
</Project>

View file

@ -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<EssentialsConfig>();
@ -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<EssentialsConfig>();
@ -160,6 +164,13 @@ namespace PepperDash.Essentials.Core.Config
{
ConfigObject.TemplateUrl = parsedConfig["template_url"].Value<string>();
}
// 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<VersionData>();
}
}
Debug.LogMessage(LogEventLevel.Information, "Successfully Loaded Merged Config");

View file

@ -105,6 +105,7 @@ namespace PepperDash.Essentials.Core.Config
/// <summary>
/// Gets or sets the Versions
/// </summary>
[JsonProperty("versions")]
public VersionData Versions { get; set; }
/// <summary>
@ -134,12 +135,25 @@ namespace PepperDash.Essentials.Core.Config
[JsonProperty("packages")]
public List<NugetVersion> Packages { get; set; }
/// <summary>
/// Gets or sets the touchpanel wrapper app version
/// </summary>
[JsonProperty("touchpanelWrapperApp")]
public NugetVersion TouchpanelWrapperApp { get; set; }
/// <summary>
/// Gets or sets the list of user interface packages
/// </summary>
[JsonProperty("userInterfaces")]
public List<NugetVersion> UserInterfaces { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="VersionData"/> class.
/// </summary>
public VersionData()
{
Packages = new List<NugetVersion>();
UserInterfaces = new List<NugetVersion>();
}
}
@ -157,8 +171,20 @@ namespace PepperDash.Essentials.Core.Config
/// <summary>
/// Gets or sets the PackageId
/// </summary>
[JsonProperty("packageId")]
[JsonProperty("packageId", NullValueHandling = NullValueHandling.Ignore)]
public string PackageId { get; set; }
/// <summary>
/// Gets or sets the RepoUrl
/// </summary>
[JsonProperty("repoUrl", NullValueHandling = NullValueHandling.Ignore)]
public string RepoUrl { get; set; }
/// <summary>
/// Gets or sets the human-readable name
/// </summary>
[JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)]
public string Name { get; set; }
}
/// <summary>

View file

@ -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",

View file

@ -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
{
/// <summary>
/// Represents a GetPackageManifestRequestHandler
/// </summary>
public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler
{
/// <summary>
/// Constructor
/// </summary>
/// <remarks>
/// base(true) enables CORS support by default
/// </remarks>
public GetPackageManifestRequestHandler()
: base(true)
{
}
/// <summary>
/// Handles GET method requests
/// </summary>
/// <param name="context"></param>
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();
}
}
/// <summary>
/// Deep-copies the config's VersionData so the live config object is never mutated
/// </summary>
private static VersionData CloneVersionData(VersionData source)
{
if (source == null)
{
return null;
}
var json = JsonConvert.SerializeObject(source);
return JsonConvert.DeserializeObject<VersionData>(json);
}
/// <summary>
/// Enriches (or creates) the essentials entry from the loaded PepperDash.Essentials.Core assembly
/// </summary>
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;
}
/// <summary>
/// Merges reflection data from loaded plugin assemblies with the config's packages list
/// </summary>
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<NugetVersion>())
.Where(p => p != null)
.ToList();
var matchedConfigPackages = new System.Collections.Generic.HashSet<NugetVersion>();
var mergedPackages = new System.Collections.Generic.List<NugetVersion>();
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
// <AssemblyMetadata Include="PackageId" Value="$(PackageId)" /> 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<AssemblyMetadataAttribute>()
.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);
}
}
}

View file

@ -0,0 +1,61 @@
using System;
using Newtonsoft.Json;
namespace PepperDash.Essentials
{
/// <summary>
/// Represents the version information reported by a connected Mobile Control UI client
/// </summary>
public class ConnectedClientVersionInfo
{
/// <summary>
/// Gets or sets the client id
/// </summary>
[JsonProperty("clientId")]
public string ClientId { get; set; }
/// <summary>
/// Gets or sets the room key the client joined
/// </summary>
[JsonProperty("roomKey")]
public string RoomKey { get; set; }
/// <summary>
/// Gets or sets the touchpanel key the client joined as, if any
/// </summary>
[JsonProperty("touchpanelKey")]
public string TouchpanelKey { get; set; }
/// <summary>
/// Gets or sets the app version reported by the client (e.g. the React app's build-time APP_VERSION)
/// </summary>
[JsonProperty("appVersion")]
public string AppVersion { get; set; }
/// <summary>
/// Gets or sets the expected app version from the system config's versions.touchpanelWrapperApp, if configured
/// </summary>
[JsonProperty("expectedAppVersion")]
public string ExpectedAppVersion { get; set; }
/// <summary>
/// Gets or sets the UTC time the client last reported this version
/// </summary>
[JsonProperty("lastSeen")]
public DateTime LastSeen { get; set; }
/// <summary>
/// Returns a copy of this instance, safe for callers outside the owning lock to hold/mutate
/// without affecting the internally tracked instance
/// </summary>
public ConnectedClientVersionInfo Clone() => new ConnectedClientVersionInfo
{
ClientId = ClientId,
RoomKey = RoomKey,
TouchpanelKey = TouchpanelKey,
AppVersion = AppVersion,
ExpectedAppVersion = ExpectedAppVersion,
LastSeen = LastSeen
};
}
}

View file

@ -69,11 +69,32 @@ namespace PepperDash.Essentials
private readonly Dictionary<string, IMobileControlMessenger> _defaultMessengers =
new Dictionary<string, IMobileControlMessenger>();
private readonly Dictionary<string, ConnectedClientVersionInfo> _connectedClientVersions =
new Dictionary<string, ConnectedClientVersionInfo>(StringComparer.InvariantCultureIgnoreCase);
private readonly object _connectedClientVersionsLock = new object();
/// <summary>
/// Get the custom messengers with subscriptions
/// </summary>
public ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions> Messengers => new ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions>(_messengers.Values.OfType<IMobileControlMessengerWithSubscriptions>().ToDictionary(k => k.Key, v => v));
/// <summary>
/// Gets the most recently reported UI app version for each connected client, keyed by clientId
/// </summary>
public ReadOnlyDictionary<string, ConnectedClientVersionInfo> ConnectedClientVersions
{
get
{
lock (_connectedClientVersionsLock)
{
return new ReadOnlyDictionary<string, ConnectedClientVersionInfo>(
_connectedClientVersions.ToDictionary(kv => kv.Key, kv => kv.Value.Clone())
);
}
}
}
/// <summary>
/// Get the default messengers
/// </summary>
@ -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"
);
}
}
}
/// <summary>
@ -2181,6 +2224,8 @@ namespace PepperDash.Essentials
var roomKey = content["roomKey"].Value<string>();
var touchpanelKey = content.SelectToken("touchpanelKey");
TrackClientAppVersion(clientId, roomKey, touchpanelKey?.Value<string>(), content.SelectToken("appVersion")?.Value<string>());
if (_roomCombiner == null)
{
var message = new MobileControlMessage
@ -2252,6 +2297,50 @@ namespace PepperDash.Essentials
SendTouchpanelKey(clientId, touchpanelKey);
}
/// <summary>
/// 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.
/// </summary>
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)