mirror of
https://github.com/PepperDash/Essentials.git
synced 2026-08-31 10:58:28 +00:00
Merge remote-tracking branch 'origin/main' into feature/screenLiftController-updates
This commit is contained in:
commit
e48bbafd01
18 changed files with 643 additions and 83 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@ namespace PepperDash.Core
|
|||
/// </summary>
|
||||
public bool IsRunning { get => _httpsServer?.IsListening ?? false; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether there are active WebSocket connections.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
|
|
@ -218,6 +232,8 @@ namespace PepperDash.Core
|
|||
Debug.LogInformation("Starting Websocket Server on port: {0}", port);
|
||||
|
||||
|
||||
|
||||
|
||||
Start(port, CertPath, _certificatePassword);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ namespace PepperDash.Essentials.Core
|
|||
|
||||
ICec Port;
|
||||
|
||||
bool _cecSubscribed;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to the CEC change event once <see cref="ICec.StreamCec"/> 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.
|
||||
/// </summary>
|
||||
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
|
|||
/// </summary>
|
||||
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
|
|||
/// </summary>
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
// 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<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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
using System;
|
||||
using PepperDash.Core;
|
||||
|
||||
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the contract for IMobileControlMessenger
|
||||
/// Obsolete: messengers are subscription based by default; use IMobileControlMessenger instead.
|
||||
/// </summary>
|
||||
[Obsolete("This interface is obsolete and will be removed in a future version. All messengers are now subscription based.")]
|
||||
public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger
|
||||
{
|
||||
/// <summary>
|
||||
|
|
|
|||
14
src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs
Normal file
14
src/PepperDash.Essentials.Core/Devices/IHasDspPresetSave.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace PepperDash.Essentials.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the contract for IHasDspPresetSave
|
||||
/// </summary>
|
||||
public interface IHasDspPresetSave : IDspPresets
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves the preset by key
|
||||
/// </summary>
|
||||
/// <param name="key">key of preset to save</param>
|
||||
void SavePreset(string key);
|
||||
}
|
||||
}
|
||||
|
|
@ -348,9 +348,9 @@ namespace PepperDash.Essentials.Core
|
|||
var sources = DeviceManager.AllDevices.OfType<IRoutingOutputs>()
|
||||
.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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
|
|||
/// </summary>
|
||||
public class DebugSessionRequestHandler : WebApiBaseRequestHandler
|
||||
{
|
||||
private CTimer _portForwardTimeoutTimer;
|
||||
private readonly object _timerLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
|
|
@ -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
|
|||
/// <param name="context"></param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the port forward timeout timer if a session is being explicitly stopped.
|
||||
/// </summary>
|
||||
private void CancelPortForwardTimeout()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
_portForwardTimeoutTimer?.Dispose();
|
||||
_portForwardTimeoutTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<object>(),
|
||||
IntValues = Array.Empty<object>(),
|
||||
SerialValues = Array.Empty<object>()
|
||||
};
|
||||
var respJs = JsonConvert.SerializeObject(resp, Formatting.Indented);
|
||||
|
||||
context.Response.Write(respJs, false);
|
||||
|
||||
context.Response.StatusCode = 404;
|
||||
context.Response.StatusDescription = "Not Found";
|
||||
context.Response.End();
|
||||
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
|
|||
/// The interfaces implmented by the device sending the messsage
|
||||
/// </summary>
|
||||
[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<string> Interfaces { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Enable subscriptions for Messengers
|
||||
/// Enables subscriptions for messengers
|
||||
/// </summary>
|
||||
[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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue