Essentials/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs
jkdevito 4631d3a537 refactor: replace client-reported UI app version tracking with disk-based check
Remove ConnectedClientVersionInfo and the per-client version dictionary in
MobileControlSystemController, which grew unbounded and depended on a UI
client connecting and self-reporting its APP_VERSION.

Add TouchpanelWrapperAppVersionChecker, which instead scans the deployed
mcUserApp files on disk for the configured versions.touchpanelWrapperApp
version string. This works without any client connected and doesn't
accumulate state. Wired into the mobileinfo console command output.
2026-07-10 12:25:43 -05:00

1869 lines
No EOL
63 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Timers;
using Crestron.SimplSharp;
using Crestron.SimplSharp.CrestronIO;
using Crestron.SimplSharp.Net.Http;
using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PepperDash.Core;
using PepperDash.Core.Logging;
using PepperDash.Essentials.AppServer;
using PepperDash.Essentials.AppServer.Messengers;
using PepperDash.Essentials.Core;
using PepperDash.Essentials.Core.Config;
using PepperDash.Essentials.Core.DeviceTypeInterfaces;
using PepperDash.Essentials.Core.Monitoring;
using PepperDash.Essentials.Core.Queues;
using PepperDash.Essentials.Core.Web;
using PepperDash.Essentials.RoomBridges;
using PepperDash.Essentials.Services;
using PepperDash.Essentials.WebApiHandlers;
using PepperDash.Essentials.WebSocketServer;
using WebSocketSharp;
namespace PepperDash.Essentials
{
/// <summary>
/// Represents a MobileControlSystemController
/// </summary>
public class MobileControlSystemController : EssentialsDevice, IMobileControl
{
private bool _initialized = false;
private const long ServerReconnectInterval = 5000;
private const long PingInterval = 25000;
private readonly Dictionary<string, List<IMobileControlAction>> _actionDictionary =
new Dictionary<string, List<IMobileControlAction>>(
StringComparer.InvariantCultureIgnoreCase
);
/// <summary>
/// Actions
/// </summary>
public ReadOnlyDictionary<string, List<IMobileControlAction>> ActionDictionary => new ReadOnlyDictionary<string, List<IMobileControlAction>>(_actionDictionary);
private readonly GenericQueue _receiveQueue;
private readonly List<MobileControlBridgeBase> _roomBridges =
new List<MobileControlBridgeBase>();
private readonly Dictionary<string, IMobileControlMessenger> _messengers =
new Dictionary<string, IMobileControlMessenger>();
private readonly Dictionary<string, IMobileControlMessenger> _defaultMessengers =
new Dictionary<string, IMobileControlMessenger>();
/// <summary>
/// Get the custom messengers
/// </summary>
public ReadOnlyDictionary<string, IMobileControlMessenger> Messengers => new ReadOnlyDictionary<string, IMobileControlMessenger>(_messengers);
/// <summary>
/// Get the default messengers
/// </summary>
public ReadOnlyDictionary<string, IMobileControlMessenger> DefaultMessengers => new ReadOnlyDictionary<string, IMobileControlMessenger>(_defaultMessengers);
private readonly GenericQueue _transmitToServerQueue;
private readonly GenericQueue _transmitToClientsQueue;
private bool _disableReconnect;
private WebSocket _wsClient2;
/// <summary>
/// Gets or sets the ApiService
/// </summary>
public MobileControlApiService ApiService { get; private set; }
/// <summary>
/// Get Room Bridges associated with this controller
/// </summary>
public List<MobileControlBridgeBase> RoomBridges => _roomBridges;
private readonly MobileControlWebsocketServer _directServer;
/// <summary>
/// Get the Direct Server instance associated with this controller
/// </summary>
public MobileControlWebsocketServer DirectServer => _directServer;
private readonly object _wsCriticalSection = new();
/// <summary>
/// Gets or sets the SystemUrl
/// </summary>
public string SystemUrl; //set only from SIMPL Bridge!
/// <summary>
/// True if the Mobile Control Edge Server Websocket is connected
/// </summary>
public bool Connected => _wsClient2 != null && _wsClient2.IsAlive;
private IEssentialsRoomCombiner _roomCombiner;
/// <summary>
/// Gets the SystemUuid from configuration or SIMPL Bridge
/// </summary>
public string SystemUuid
{
get
{
// Check to see if the SystemUuid value is populated. If not populated from configuration, check for value from SIMPL bridge.
if (
!string.IsNullOrEmpty(ConfigReader.ConfigObject.SystemUuid)
&& ConfigReader.ConfigObject.SystemUuid != "missing url"
)
{
return ConfigReader.ConfigObject.SystemUuid;
}
this.LogWarning(
"No system_url value defined in config. Checking for value from SIMPL Bridge."
);
if (string.IsNullOrEmpty(SystemUrl))
{
this.LogError(
"No system_url value defined in config or SIMPL Bridge. Unable to connect to Mobile Control."
);
return string.Empty;
}
var result = Regex.Match(SystemUrl, @"https?:\/\/.*\/systems\/(.*)\/#.*");
string uuid = result.Groups[1].Value;
return uuid;
}
}
/// <summary>
/// Gets or sets the ApiOnlineAndAuthorized
/// </summary>
public BoolFeedback ApiOnlineAndAuthorized { get; private set; }
/// <summary>
/// Used for tracking HTTP debugging
/// </summary>
private bool _httpDebugEnabled;
private bool _isAuthorized;
/// <summary>
/// Tracks if the system is authorized to the API server
/// </summary>
public bool IsAuthorized
{
get { return _isAuthorized; }
private set
{
if (value == _isAuthorized)
return;
_isAuthorized = value;
ApiOnlineAndAuthorized.FireUpdate();
}
}
private DateTime _lastAckMessage;
/// <summary>
/// Gets the LastAckMessage timestamp
/// </summary>
public DateTime LastAckMessage => _lastAckMessage;
private Timer _pingTimer;
private Timer _serverReconnectTimer;
private LogLevel _wsLogLevel = LogLevel.Error;
/// <summary>
/// Initializes a new instance of the <see cref="MobileControlSystemController"/> class.
/// </summary>
/// <param name="key">The unique key for this controller.</param>
/// <param name="name">The name of the controller.</param>
/// <param name="config">The configuration settings for the controller.</param>
public MobileControlSystemController(string key, string name, MobileControlConfig config)
: base(key, name)
{
Config = config;
// The queue that will collect the incoming messages in the order they are received
//_receiveQueue = new ReceiveQueue(key, ParseStreamRx);
_receiveQueue = new GenericQueue(
key + "-rxqueue",
System.Threading.ThreadPriority.Highest,
100
);
// The queue that will collect the outgoing messages in the order they are received
_transmitToServerQueue = new GenericQueue(
key + "-txqueue",
System.Threading.ThreadPriority.Highest,
100
);
if (Config.DirectServer != null && Config.DirectServer.EnableDirectServer)
{
_directServer = new MobileControlWebsocketServer(
Key + "-directServer",
Config.DirectServer.Port,
this
);
DeviceManager.AddDevice(_directServer);
_transmitToClientsQueue = new GenericQueue(
key + "-clienttxqueue",
System.Threading.ThreadPriority.Highest,
100
);
}
Host = config.ServerUrl;
if (!Host.StartsWith("http"))
{
Host = "https://" + Host;
}
ApiService = new MobileControlApiService(Host);
this.LogInformation(
"Mobile UI controller initializing for server:{0}",
config.ServerUrl
);
if (Global.Platform == eDevicePlatform.Appliance)
{
AddConsoleCommands();
}
AddPreActivationAction(() => LinkSystemMonitorToAppServer());
AddPreActivationAction(() =>
{
var actionMessenger = new DeviceActionMessenger("deviceActionMessenger-" + Key, "/action");
AddDeviceMessenger(actionMessenger);
});
AddPreActivationAction(() => AddWebApiPaths());
AddPreActivationAction(() =>
{
_roomCombiner = DeviceManager.AllDevices.OfType<IEssentialsRoomCombiner>().FirstOrDefault();
if (_roomCombiner == null)
return;
_roomCombiner.RoomCombinationScenarioChanged += OnRoomCombinationScenarioChanged;
});
CrestronEnvironment.ProgramStatusEventHandler +=
CrestronEnvironment_ProgramStatusEventHandler;
ApiOnlineAndAuthorized = new BoolFeedback("apiOnlineAndAuthorized", () =>
{
if (_wsClient2 == null)
return false;
return _wsClient2.IsAlive && IsAuthorized;
});
}
/// <inheritdoc />
public void AddDefaultMessengersForDevice(EssentialsDevice device, IEnumerable<Type> interfaces = null)
{
var interfaceSet = interfaces != null ? new HashSet<Type>(interfaces) : [.. device.GetType().GetInterfaces()];
var messengerAdded = false;
foreach (var entry in MessengerFactoryRegistry.Entries)
{
if (interfaceSet != null && !interfaceSet.Contains(entry.InterfaceType))
continue;
if (!entry.Matches(device))
continue;
var messenger = entry.Factory(device, $"/device/{device.Key}", Key);
if (messenger == null)
continue;
AddDefaultDeviceMessenger(messenger);
messengerAdded = true;
}
if (!messengerAdded)
{
this.LogDebug("Adding GenericMessenger for {deviceKey}", device.Key);
AddDefaultDeviceMessenger(new GenericMessenger(
$"{device.Key}-{Key}-generic",
device,
$"/device/{device.Key}"));
}
}
private void AddWebApiPaths()
{
var apiServer = DeviceManager
.AllDevices.OfType<EssentialsWebApi>()
.FirstOrDefault(d => d.Key == "essentialsWebApi");
if (apiServer == null)
{
this.LogWarning("No API Server available");
return;
}
// TODO: Add routes for the rest of the MC console commands
var routes = new List<HttpCwsRoute>
{
new HttpCwsRoute($"device/{Key}/authorize")
{
Name = "MobileControlAuthorize",
RouteHandler = new MobileAuthRequestHandler(this)
},
new HttpCwsRoute($"device/{Key}/info")
{
Name = "MobileControlInformation",
RouteHandler = new MobileInfoHandler(this)
},
new HttpCwsRoute($"device/{Key}/actionPaths")
{
Name = "MobileControlActionPaths",
RouteHandler = new ActionPathsHandler(this)
}
};
apiServer.AddRoute(routes);
}
private void AddConsoleCommands()
{
CrestronConsole.AddNewConsoleCommand(
AuthorizeSystem,
"mobileauth",
"Authorizes system to talk to Mobile Control server",
ConsoleAccessLevelEnum.AccessOperator
);
CrestronConsole.AddNewConsoleCommand(
s => ShowInfo(),
"mobileinfo",
"Shows information for current mobile control session",
ConsoleAccessLevelEnum.AccessOperator
);
CrestronConsole.AddNewConsoleCommand(
s =>
{
s = s.Trim();
if (!string.IsNullOrEmpty(s))
{
_httpDebugEnabled = (s.Trim() != "0");
}
CrestronConsole.ConsoleCommandResponse(
"HTTP Debug {0}",
_httpDebugEnabled ? "Enabled" : "Disabled"
);
},
"mobilehttpdebug",
"1 enables more verbose HTTP response debugging",
ConsoleAccessLevelEnum.AccessOperator
);
CrestronConsole.AddNewConsoleCommand(
TestHttpRequest,
"mobilehttprequest",
"Tests an HTTP get to URL given",
ConsoleAccessLevelEnum.AccessOperator
);
CrestronConsole.AddNewConsoleCommand(
PrintActionDictionaryPaths,
"mobileshowactionpaths",
"Prints the paths in the Action Dictionary",
ConsoleAccessLevelEnum.AccessOperator
);
CrestronConsole.AddNewConsoleCommand(
s =>
{
_disableReconnect = false;
CrestronConsole.ConsoleCommandResponse(
$"Connecting to MC API server"
);
ConnectWebsocketClient();
},
"mobileconnect",
"Forces connect of websocket",
ConsoleAccessLevelEnum.AccessOperator
);
CrestronConsole.AddNewConsoleCommand(
s =>
{
_disableReconnect = true;
CleanUpWebsocketClient();
CrestronConsole.ConsoleCommandResponse(
$"Disonnected from MC API server"
);
},
"mobiledisco",
"Disconnects websocket",
ConsoleAccessLevelEnum.AccessOperator
);
CrestronConsole.AddNewConsoleCommand(
ParseStreamRx,
"mobilesimulateaction",
"Simulates a message from the server",
ConsoleAccessLevelEnum.AccessOperator
);
CrestronConsole.AddNewConsoleCommand(
SetWebsocketDebugLevel,
"mobilewsdebug",
"Set Websocket debug level",
ConsoleAccessLevelEnum.AccessProgrammer
);
}
/// <summary>
/// Gets or sets the Config
/// </summary>
public MobileControlConfig Config { get; private set; }
/// <summary>
/// Gets or sets the Host
/// </summary>
public string Host { get; private set; }
/// <summary>
/// Gets the configured Client App URL
/// </summary>
public string ClientAppUrl => Config.ClientAppUrl;
private void OnRoomCombinationScenarioChanged(
object sender,
EventArgs eventArgs
)
{
SendMessageObject(new MobileControlMessage { Type = "/system/roomCombinationChanged" });
}
/// <summary>
/// Checks if a device messenger exists for the given key.
/// </summary>
public bool CheckForDeviceMessenger(string key)
{
return _messengers.ContainsKey(key);
}
/// <summary>
/// Add the provided messenger to the messengers collection
/// </summary>
public void AddDeviceMessenger(IMobileControlMessenger messenger)
{
if (_messengers.ContainsKey(messenger.Key))
{
this.LogWarning("Messenger with key {messengerKey} already added", messenger.Key);
return;
}
if (messenger is IDelayedConfiguration simplMessenger)
{
simplMessenger.ConfigurationIsReady += Bridge_ConfigurationIsReady;
}
if (messenger is MobileControlBridgeBase roomBridge)
{
_roomBridges.Add(roomBridge);
}
this.LogVerbose(
"Adding messenger with key {messengerKey} for path {messengerPath}",
messenger.Key,
messenger.MessagePath
);
_messengers.Add(messenger.Key, messenger);
if (_initialized)
{
RegisterMessengerWithServer(messenger);
}
}
private void AddDefaultDeviceMessenger(IMobileControlMessenger messenger)
{
if (_defaultMessengers.ContainsKey(messenger.Key))
{
this.LogWarning(
"Default messenger with key {messengerKey} already added",
messenger.Key
);
return;
}
if (messenger is IDelayedConfiguration simplMessenger)
{
simplMessenger.ConfigurationIsReady += Bridge_ConfigurationIsReady;
}
this.LogVerbose(
"Adding default messenger with key {messengerKey} for path {messengerPath}",
messenger.Key,
messenger.MessagePath
);
_defaultMessengers.Add(messenger.Key, messenger);
if (_initialized)
{
RegisterMessengerWithServer(messenger);
}
}
private void RegisterMessengerWithServer(IMobileControlMessenger messenger)
{
this.LogVerbose(
"Registering messenger with key {messengerKey} for path {messengerPath}",
messenger.Key,
messenger.MessagePath
);
messenger.RegisterWithAppServer(this);
}
/// <inheritdoc />
protected override void Initialize()
{
foreach (var messenger in _messengers)
{
try
{
RegisterMessengerWithServer(messenger.Value);
}
catch (Exception ex)
{
this.LogException(ex, "Exception registering custom messenger {messengerKey}", messenger.Key);
continue;
}
}
foreach (var messenger in _defaultMessengers)
{
try
{
RegisterMessengerWithServer(messenger.Value);
}
catch (Exception ex)
{
this.LogException(ex, "Exception registering default messenger {messengerKey}", messenger.Key);
continue;
}
}
var simplMessengers = _messengers.Values.OfType<IDelayedConfiguration>().ToList();
if (simplMessengers.Count > 0)
{
return;
}
_initialized = true;
RegisterSystemToServer();
}
#region IMobileControl Members
/// <summary>
/// Gets the App Server instance
/// </summary>
public static IMobileControl GetAppServer()
{
try
{
var appServer =
DeviceManager.GetDevices().SingleOrDefault(s => s is IMobileControl)
as MobileControlSystemController;
return appServer;
}
catch (Exception e)
{
Debug.LogMessage(e, "Unable to find MobileControlSystemController in Devices");
return null;
}
}
private bool CreateWebsocket()
{
_wsClient2?.Close();
_wsClient2 = null;
if (string.IsNullOrEmpty(SystemUuid))
{
this.LogError(
"System UUID not defined. Unable to connect to Mobile Control"
);
return false;
}
var wsHost = Host.Replace("http", "ws");
var url = string.Format("{0}/system/join/{1}", wsHost, SystemUuid);
_wsClient2 = new WebSocket(url)
{
Log =
{
Output = (data, message) => Utilities.ConvertWebsocketLog(data, message, this)
}
};
// setting to trace to let level be controlled by appdebug
_wsClient2.Log.Level = LogLevel.Trace;
_wsClient2.SslConfiguration.EnabledSslProtocols =
// System.Security.Authentication.SslProtocols.Tls11
System.Security.Authentication.SslProtocols.Tls12
| System.Security.Authentication.SslProtocols.Tls13;
_wsClient2.OnMessage += HandleMessage;
_wsClient2.OnOpen += HandleOpen;
_wsClient2.OnError += HandleError;
_wsClient2.OnClose += HandleClose;
return true;
}
/// <summary>
/// Link the System Monitor to this App server
/// </summary>
public void LinkSystemMonitorToAppServer()
{
if (CrestronEnvironment.DevicePlatform != eDevicePlatform.Appliance)
{
this.LogWarning(
"System Monitor does not exist for this platform. Skipping..."
);
return;
}
if (!(DeviceManager.GetDeviceForKey("systemMonitor") is SystemMonitorController sysMon))
{
return;
}
var key = sysMon.Key + "-" + Key;
var messenger = new SystemMonitorMessenger(key, sysMon, "/device/systemMonitor");
AddDeviceMessenger(messenger);
}
#endregion
private void SetWebsocketDebugLevel(string cmdparameters)
{
if (string.IsNullOrEmpty(cmdparameters))
{
this.LogInformation("Current Websocket debug level: {webSocketDebugLevel}", _wsLogLevel);
return;
}
if (cmdparameters.ToLower().Contains("help") || cmdparameters.ToLower().Contains("?"))
{
CrestronConsole.ConsoleCommandResponse(
$"valid options are:\r\n{LogLevel.Trace}\r\n{LogLevel.Debug}\r\n{LogLevel.Info}\r\n{LogLevel.Warn}\r\n{LogLevel.Error}\r\n{LogLevel.Fatal}\r\n"
);
}
try
{
var debugLevel = (LogLevel)Enum.Parse(typeof(LogLevel), cmdparameters, true);
_wsLogLevel = debugLevel;
if (_wsClient2 != null)
{
_wsClient2.Log.Level = _wsLogLevel;
}
_directServer?.SetWebsocketLogLevel(_wsLogLevel);
CrestronConsole.ConsoleCommandResponse($"Websocket log level set to {debugLevel}");
}
catch
{
CrestronConsole.ConsoleCommandResponse(
$"{cmdparameters} is not a valid debug level. Valid options are:\r\n{LogLevel.Trace}\r\n{LogLevel.Debug}\r\n{LogLevel.Info}\r\n{LogLevel.Warn}\r\n{LogLevel.Error}\r\n{LogLevel.Fatal}\r\n"
);
}
}
private void CrestronEnvironment_ProgramStatusEventHandler(
eProgramStatusEventType programEventType
)
{
if (
programEventType != eProgramStatusEventType.Stopping
|| _wsClient2 == null
|| !_wsClient2.IsAlive
)
{
return;
}
_disableReconnect = true;
StopServerReconnectTimer();
CleanUpWebsocketClient();
}
/// <summary>
/// PrintActionDictionaryPaths method
/// </summary>
public void PrintActionDictionaryPaths(object o)
{
CrestronConsole.ConsoleCommandResponse("ActionDictionary Contents:\r\n");
foreach (var (messengerKey, actionPath) in GetActionDictionaryPaths())
{
CrestronConsole.ConsoleCommandResponse($"<{messengerKey}> {actionPath}\r\n");
}
}
/// <summary>
/// Get action paths for the current actions
/// </summary>
public List<(string, string)> GetActionDictionaryPaths()
{
var paths = new List<(string, string)>();
foreach (var item in _actionDictionary)
{
var messengers = item.Value.Select(a => a.Messenger).Cast<MessengerBase>();
foreach (var messenger in messengers)
{
foreach (var actionPath in messenger.GetActionPaths())
{
paths.Add((messenger.Key, $"{item.Key}{actionPath}"));
}
}
}
return paths;
}
/// <summary>
/// Adds an action to the dictionary
/// </summary>
/// <param name="messenger">The messenger for the API command</param>
/// <param name="action">The action to be triggered by the commmand</param>
public void AddAction<T>(T messenger, Action<string, string, JToken> action)
where T : IMobileControlMessenger
{
if (
_actionDictionary.TryGetValue(
messenger.MessagePath,
out List<IMobileControlAction> actionList
)
)
{
if (
actionList.Any(a =>
a.Messenger.GetType() == messenger.GetType()
&& a.Messenger.DeviceKey == messenger.DeviceKey
)
)
{
this.LogWarning("Messenger of type {messengerType} already exists. Skipping actions for {messengerKey}", messenger.GetType().Name, messenger.Key);
return;
}
actionList.Add(new MobileControlAction(messenger, action));
return;
}
actionList = new List<IMobileControlAction>
{
new MobileControlAction(messenger, action)
};
_actionDictionary.Add(messenger.MessagePath, actionList);
}
/// <summary>
/// Removes an action from the dictionary
/// </summary>
/// <param name="key"></param>
/// <summary>
/// RemoveAction method
/// </summary>
public void RemoveAction(string key)
{
if (_actionDictionary.ContainsKey(key))
{
_actionDictionary.Remove(key);
}
}
/// <summary>
/// Get the room bridge with the provided key
/// </summary>
/// <param name="key">The key of the room bridge</param>
public MobileControlBridgeBase GetRoomBridge(string key)
{
return _roomBridges.FirstOrDefault((r) => r.RoomKey.Equals(key));
}
/// <summary>
/// Get the room messenger with the provided key
/// </summary>
/// <param name="key">The Key of the rooom messenger</param>
public IMobileControlRoomMessenger GetRoomMessenger(string key)
{
return _roomBridges.FirstOrDefault((r) => r.RoomKey.Equals(key));
}
private void Bridge_ConfigurationIsReady(object sender, EventArgs e)
{
this.LogDebug("Bridge ready. Registering");
// send the configuration object to the server
if (_wsClient2 == null)
{
RegisterSystemToServer();
}
else if (!_wsClient2.IsAlive)
{
ConnectWebsocketClient();
}
else
{
SendInitialMessage();
}
}
private void ReconnectToServerTimerCallback(object o)
{
this.LogDebug("Attempting to reconnect to server...");
ConnectWebsocketClient();
}
private void AuthorizeSystem(string code)
{
if (
string.IsNullOrEmpty(SystemUuid)
|| SystemUuid.Equals("missing url", StringComparison.OrdinalIgnoreCase)
)
{
CrestronConsole.ConsoleCommandResponse(
"System does not have a UUID. Please ensure proper configuration is loaded and restart."
);
return;
}
if (string.IsNullOrEmpty(code))
{
CrestronConsole.ConsoleCommandResponse(
"Please enter a grant code to authorize a system"
);
return;
}
if (string.IsNullOrEmpty(Config.ServerUrl))
{
CrestronConsole.ConsoleCommandResponse(
"Mobile control API address is not set. Check portal configuration"
);
return;
}
var authTask = ApiService.SendAuthorizationRequest(Host, code, SystemUuid);
authTask.ContinueWith(t =>
{
var response = t.Result;
if (response.Authorized)
{
this.LogDebug("System authorized, sending config.");
RegisterSystemToServer();
return;
}
this.LogInformation(response.Reason);
});
}
private void ShowInfo()
{
var url = Config != null ? Host : "No config";
string name;
string code;
if (_roomBridges != null && _roomBridges.Count > 0)
{
name = _roomBridges[0].RoomName;
code = _roomBridges[0].UserCode;
}
else
{
name = "No config";
code = "Not available";
}
var conn = _wsClient2 == null ? "No client" : (_wsClient2.IsAlive ? "Yes" : "No");
var secSinceLastAck = DateTime.Now - _lastAckMessage;
if (Config.EnableApiServer)
{
CrestronConsole.ConsoleCommandResponse(
"Mobile Control Edge Server API Information:\r\n\r\n" +
"\tServer address: {0}\r\n" +
"\tSystem Name: {1}\r\n" +
"\tSystem URL: {2}\r\n" +
"\tSystem UUID: {3}\r\n" +
"\tSystem User code: {4}\r\n" +
"\tConnected?: {5}\r\n" +
"\tSeconds Since Last Ack: {6}\r\n",
url,
name,
ConfigReader.ConfigObject.SystemUrl,
SystemUuid,
code,
conn,
secSinceLastAck.Seconds
);
}
else
{
CrestronConsole.ConsoleCommandResponse(
"\r\nMobile Control Edge Server API Information:\r\n" +
" Not Enabled in Config.\r\n"
);
}
if (
Config.DirectServer != null
&& Config.DirectServer.EnableDirectServer
&& _directServer != null
)
{
CrestronConsole.ConsoleCommandResponse(
"\r\nMobile Control Direct Server Information:\r\n" +
" User App URL: {0}\r\n" +
" Server port: {1}\r\n",
string.Format("{0}[insert_client_token]", _directServer.UserAppUrlPrefix),
_directServer.Port
);
CrestronConsole.ConsoleCommandResponse(
"\r\n UI Client Info:\r\n" +
" Tokens Defined: {0}\r\n" +
" Clients Connected: {1}\r\n",
_directServer.UiClientContexts.Count,
_directServer.ConnectedUiClientsCount
);
var clientNo = 1;
foreach (var clientContext in _directServer.UiClientContexts)
{
var clients = _directServer.UiClients.Values.Where(c => c.TokenKey == clientContext.Key);
CrestronConsole.ConsoleCommandResponse(
$"\r\nClient {clientNo}:\r\n" +
$" Room Key: {clientContext.Value.Token.RoomKey}\r\n" +
$" Touchpanel Key: {clientContext.Value.Token.TouchpanelKey}\r\n" +
$" Token: {clientContext.Key}\r\n" +
$" Client URL: {_directServer.UserAppUrlPrefix}{clientContext.Key}\r\n" +
$" Clients:\r\n"
);
if (!clients.Any())
{
CrestronConsole.ConsoleCommandResponse(" No clients connected");
}
foreach (var client in clients)
{
CrestronConsole.ConsoleCommandResponse(
$" ID: {client.Id}\r\n" +
$" Connected: {client.Context.WebSocket.IsAlive}\r\n" +
$" Duration: {(client.Context.WebSocket.IsAlive ? client.ConnectedDuration.TotalSeconds.ToString() : "Not Connected")}\r\n"
);
}
clientNo++;
}
}
else
{
CrestronConsole.ConsoleCommandResponse(
"\r\nMobile Control Direct Server Information:\r\n" +
" 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"
);
}
/// <summary>
/// Register this system to the Mobile Control Edge Server
/// </summary>
public void RegisterSystemToServer()
{
if (!Config.EnableApiServer)
{
this.LogInformation(
"ApiServer disabled via config. Cancelling attempt to register to server."
);
return;
}
var result = CreateWebsocket();
if (!result)
{
this.LogFatal("Unable to create websocket.");
return;
}
ConnectWebsocketClient();
}
private void ConnectWebsocketClient()
{
lock (_wsCriticalSection)
{
// set to 99999 to let things work on 4-Series
if (
(CrestronEnvironment.ProgramCompatibility & eCrestronSeries.Series4)
== eCrestronSeries.Series4
)
{
_wsClient2.Log.Level = (LogLevel)99999;
}
else if (
(CrestronEnvironment.ProgramCompatibility & eCrestronSeries.Series3)
== eCrestronSeries.Series3
)
{
_wsClient2.Log.Level = _wsLogLevel;
}
//This version of the websocket client is TLS1.2 ONLY
//Fires OnMessage event when PING is received.
_wsClient2.EmitOnPing = true;
this.LogDebug(
"Connecting mobile control client to {mobileControlUrl}",
_wsClient2.Url
);
TryConnect();
}
}
private void TryConnect()
{
try
{
IsAuthorized = false;
_wsClient2.Connect();
}
catch (InvalidOperationException)
{
this.LogError(
"Maximum retries exceeded. Restarting websocket"
);
HandleConnectFailure();
}
catch (IOException ex)
{
this.LogException(ex, "IO Exception on connect");
HandleConnectFailure();
}
catch (Exception ex)
{
this.LogException(
ex,
"Error on Websocket Connect"
);
HandleConnectFailure();
}
}
private void HandleConnectFailure()
{
_wsClient2 = null;
var wsHost = Host.Replace("http", "ws");
var url = string.Format("{0}/system/join/{1}", wsHost, SystemUuid);
_wsClient2 = new WebSocket(url)
{
Log =
{
Output = (data, s) =>
this.LogDebug(
"Message from websocket: {message}",
data
)
}
};
_wsClient2.OnMessage -= HandleMessage;
_wsClient2.OnOpen -= HandleOpen;
_wsClient2.OnError -= HandleError;
_wsClient2.OnClose -= HandleClose;
_wsClient2.OnMessage += HandleMessage;
_wsClient2.OnOpen += HandleOpen;
_wsClient2.OnError += HandleError;
_wsClient2.OnClose += HandleClose;
StartServerReconnectTimer();
}
private void HandleOpen(object sender, EventArgs e)
{
StopServerReconnectTimer();
StartPingTimer();
this.LogInformation("Mobile Control API connected");
SendMessageObject(new MobileControlMessage { Type = "hello" });
}
private void HandleMessage(object sender, MessageEventArgs e)
{
if (e.IsPing)
{
_lastAckMessage = DateTime.Now;
IsAuthorized = true;
ResetPingTimer();
return;
}
if (e.IsText && e.Data.Length > 0)
{
_receiveQueue.Enqueue(new ProcessStringMessage(e.Data, ParseStreamRx));
}
}
private void HandleError(object sender, ErrorEventArgs e)
{
this.LogError("Websocket error {0}", e.Message);
IsAuthorized = false;
StartServerReconnectTimer();
}
private void HandleClose(object sender, CloseEventArgs e)
{
this.LogDebug(
"Websocket close {code} {reason}, clean={wasClean}",
e.Code,
e.Reason,
e.WasClean
);
IsAuthorized = false;
StopPingTimer();
// Start the reconnect timer only if disableReconnect is false and the code isn't 4200. 4200 indicates system is not authorized;
if (_disableReconnect || e.Code == 4200)
{
return;
}
StartServerReconnectTimer();
}
private void SendInitialMessage()
{
this.LogInformation("Sending initial join message");
var touchPanels = DeviceManager
.AllDevices.OfType<IMobileControlTouchpanelController>()
.Where(tp => !tp.UseDirectServer)
.Select(
(tp) =>
{
return new { touchPanelKey = tp.Key, roomKey = tp.DefaultRoomKey };
}
);
var msg = new MobileControlMessage
{
Type = "join",
Content = JToken.FromObject(
new { config = GetConfigWithPluginVersion(), touchPanels }
)
};
SendMessageObject(msg);
}
/// <summary>
/// Get the Essentials configuration with version data
/// </summary>
public MobileControlEssentialsConfig GetConfigWithPluginVersion()
{
// Populate the application name and version number
var confObject = new MobileControlEssentialsConfig(ConfigReader.ConfigObject);
confObject.Info.RuntimeInfo.AppName = Assembly.GetExecutingAssembly().GetName().Name;
var essentialsVersion = Global.AssemblyVersion;
confObject.Info.RuntimeInfo.AssemblyVersion = essentialsVersion;
// // Set for local testing
// confObject.RuntimeInfo.PluginVersion = "4.0.0-localBuild";
// Populate the plugin version
var pluginVersion = Assembly
.GetExecutingAssembly()
.GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false);
if (pluginVersion[0] is AssemblyInformationalVersionAttribute fullVersionAtt)
{
var pluginInformationalVersion = fullVersionAtt.InformationalVersion;
confObject.RuntimeInfo.PluginVersion = pluginInformationalVersion;
confObject.RuntimeInfo.EssentialsVersion = Global.AssemblyVersion;
confObject.RuntimeInfo.PepperDashCoreVersion = PluginLoader.PepperDashCoreAssembly.Version;
confObject.RuntimeInfo.EssentialsPlugins = PluginLoader.EssentialsPluginAssemblies;
}
return confObject;
}
/// <summary>
/// Set the Client URL for a given room
/// </summary>
/// <param name="path">new App URL</param>
/// <param name="roomKey">room key. Default is null</param>
/// <remarks>
/// If roomKey is null, the URL will be set for the entire system.
/// </remarks>
public void SetClientUrl(string path, string roomKey = null)
{
var message = new MobileControlMessage
{
Type = string.IsNullOrEmpty(roomKey) ? $"/event/system/setUrl" : $"/event/room/{roomKey}/setUrl",
Content = JToken.FromObject(new MobileControlSimpleContent<string> { Value = path })
};
SendMessageObject(message);
}
/// <summary>
/// Sends any object type to server
/// </summary>
/// <param name="o"></param>
public void SendMessageObject(IMobileControlMessage o)
{
if (Config.EnableApiServer)
{
_transmitToServerQueue.Enqueue(new TransmitMessage(o, _wsClient2));
}
if (
Config.DirectServer != null
&& Config.DirectServer.EnableDirectServer
&& _directServer != null
)
{
_transmitToClientsQueue.Enqueue(new MessageToClients(o, _directServer));
}
}
/// <summary>
/// Send a message to a client using the Direct Server
/// </summary>
/// <param name="o">object to send</param>
public void SendMessageObjectToDirectClient(object o)
{
if (
Config.DirectServer != null
&& Config.DirectServer.EnableDirectServer
&& _directServer != null
)
{
_transmitToClientsQueue.Enqueue(new MessageToClients(o, _directServer));
}
}
private void CleanUpWebsocketClient()
{
if (_wsClient2 == null)
{
return;
}
this.LogDebug("Disconnecting websocket");
_wsClient2.Close();
}
private void ResetPingTimer()
{
// This tells us we're online with the API and getting pings
_pingTimer.Stop();
_pingTimer.Interval = PingInterval;
_pingTimer.Start();
}
private void StartPingTimer()
{
StopPingTimer();
_pingTimer = new Timer(PingInterval) { AutoReset = false };
_pingTimer.Elapsed += (s, e) => PingTimerCallback(null);
_pingTimer.Start();
}
private void StopPingTimer()
{
if (_pingTimer == null)
{
return;
}
_pingTimer.Stop();
_pingTimer.Dispose();
_pingTimer = null;
}
private void PingTimerCallback(object o)
{
this.LogDebug(
"Ping timer expired. Closing websocket"
);
try
{
_wsClient2.Close();
}
catch (Exception ex)
{
this.LogException(ex,
"Exception closing websocket"
);
HandleConnectFailure();
}
}
private void StartServerReconnectTimer()
{
StopServerReconnectTimer();
_serverReconnectTimer = new Timer(ServerReconnectInterval) { AutoReset = false };
_serverReconnectTimer.Elapsed += (s, e) => ReconnectToServerTimerCallback(null);
_serverReconnectTimer.Start();
this.LogDebug("Reconnect Timer Started.");
}
private void StopServerReconnectTimer()
{
if (_serverReconnectTimer == null)
{
return;
}
_serverReconnectTimer.Stop();
_serverReconnectTimer = null;
}
private void HandleHeartBeat(JToken content)
{
SendMessageObject(new MobileControlMessage { Type = "/system/heartbeatAck" });
var code = content["userCode"];
if (code == null)
{
return;
}
foreach (var b in _roomBridges)
{
b.SetUserCode(code.Value<string>());
}
}
private void HandleClientJoined(JToken content)
{
var clientId = content["clientId"].Value<string>();
var roomKey = content["roomKey"].Value<string>();
var touchpanelKey = content.SelectToken("touchpanelKey");
if (_roomCombiner == null)
{
var message = new MobileControlMessage
{
Type = "/system/roomKey",
ClientId = clientId,
Content = roomKey
};
SendMessageObject(message);
SendDeviceInterfaces(clientId);
SendTouchpanelKey(clientId, touchpanelKey);
return;
}
if (_roomCombiner.CurrentScenario == null)
{
var message = new MobileControlMessage
{
Type = "/system/roomKey",
ClientId = clientId,
Content = roomKey
};
SendMessageObject(message);
SendDeviceInterfaces(clientId);
SendTouchpanelKey(clientId, touchpanelKey);
return;
}
if (!_roomCombiner.CurrentScenario.UiMap.ContainsKey(roomKey))
{
this.LogWarning(
"Unable to find correct roomKey for {roomKey} in current scenario. Returning {roomKey} as roomKey", roomKey);
var message = new MobileControlMessage
{
Type = "/system/roomKey",
ClientId = clientId,
Content = roomKey
};
SendMessageObject(message);
SendDeviceInterfaces(clientId);
SendTouchpanelKey(clientId, touchpanelKey);
return;
}
var newRoomKey = _roomCombiner.CurrentScenario.UiMap[roomKey];
var newMessage = new MobileControlMessage
{
Type = "/system/roomKey",
ClientId = clientId,
Content = newRoomKey
};
SendMessageObject(newMessage);
SendDeviceInterfaces(clientId);
SendTouchpanelKey(clientId, touchpanelKey);
}
private void SendTouchpanelKey(string clientId, JToken touchpanelKeyToken)
{
if (touchpanelKeyToken == null)
{
this.LogWarning("Touchpanel key not found for client {clientId}", clientId);
return;
}
SendMessageObject(new MobileControlMessage
{
Type = "/system/touchpanelKey",
ClientId = clientId,
Content = touchpanelKeyToken.Value<string>()
});
}
/// <summary>
/// Handles a batch request for device status. Supports two content formats:
/// <para>
/// Granular format: <c>{ "devices": { "deviceKey": ["/fullStatus", "/layoutStatus"], ... } }</c>
/// </para>
/// <para>
/// Legacy format: <c>{ "deviceKeys": ["deviceKey1", "deviceKey2"] }</c> (equivalent to /fullStatus for each)
/// </para>
/// Triggers the specified action paths for each device in parallel,
/// then sends an /system/initialSyncComplete message after all have been processed.
/// </summary>
private void HandleBatchDeviceFullStatus(string clientId, JToken content)
{
if (content == null)
{
this.LogWarning("BatchDeviceFullStatus: content is null");
return;
}
// Build a dictionary of deviceKey -> list of action paths
Dictionary<string, List<string>> deviceActionPaths;
var devicesToken = content.SelectToken("devices");
if (devicesToken != null)
{
// Granular format: { "devices": { "key": ["/path1", "/path2"], ... } }
deviceActionPaths = devicesToken.ToObject<Dictionary<string, List<string>>>();
}
else
{
// Legacy format: { "deviceKeys": ["key1", "key2"] } -> each gets /fullStatus
var deviceKeys = content.SelectToken("deviceKeys")?.ToObject<List<string>>();
if (deviceKeys == null || deviceKeys.Count == 0)
{
this.LogWarning("BatchDeviceFullStatus: No device keys or devices provided");
SendMessageObject(new MobileControlMessage
{
Type = "/system/initialSyncComplete",
ClientId = clientId
});
return;
}
deviceActionPaths = deviceKeys.ToDictionary(k => k, _ => new List<string> { "/fullStatus" });
}
if (deviceActionPaths == null || deviceActionPaths.Count == 0)
{
this.LogWarning("BatchDeviceFullStatus: Empty devices dictionary");
SendMessageObject(new MobileControlMessage
{
Type = "/system/initialSyncComplete",
ClientId = clientId
});
return;
}
this.LogInformation("BatchDeviceFullStatus: Processing {count} devices", deviceActionPaths.Count);
var tasks = new List<Task>();
foreach (var kvp in deviceActionPaths)
{
var deviceKey = kvp.Key;
var actionPaths = kvp.Value;
if (actionPaths == null || actionPaths.Count == 0)
{
continue;
}
foreach (var actionPath in actionPaths)
{
var fullPath = $"/device/{deviceKey}{actionPath}";
var handlers = _actionDictionary
.Where(kv => fullPath.StartsWith(kv.Key + "/"))
.SelectMany(kv => kv.Value)
.ToList();
if (handlers.Count == 0)
{
this.LogDebug("BatchDeviceFullStatus: No handlers for {deviceKey} at path {actionPath}", deviceKey, actionPath);
continue;
}
foreach (var handler in handlers)
{
tasks.Add(Task.Run(() =>
{
try
{
handler.Action(fullPath, clientId, JToken.FromObject(new { deviceKey }));
}
catch (Exception ex)
{
this.LogError("BatchDeviceFullStatus: Exception in handler for {deviceKey} at {actionPath}: {message}", deviceKey, actionPath, ex.Message);
}
}));
}
}
}
// After all handlers have completed and enqueued their responses, send sync complete
Task.Run(async () =>
{
try
{
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
this.LogError("BatchDeviceFullStatus: Exception waiting for tasks: {message}", ex.Message);
}
SendMessageObject(new MobileControlMessage
{
Type = "/system/initialSyncComplete",
ClientId = clientId
});
});
}
private void SendDeviceInterfaces(string clientId)
{
this.LogDebug("Sending Device interfaces");
var devices = DeviceManager.GetDevices();
Dictionary<string, DeviceInterfaceInfo> deviceInterfaces = new Dictionary<string, DeviceInterfaceInfo>();
foreach (var device in devices)
{
var interfaces = device?.GetType().GetInterfaces().Select((i) => i.Name).ToList() ?? new List<string>();
deviceInterfaces.Add(device.Key, new DeviceInterfaceInfo
{
Key = device.Key,
Name = (device as IKeyName)?.Name ?? "",
Interfaces = interfaces
});
}
var message = new MobileControlMessage
{
Type = "/system/deviceInterfaces",
ClientId = clientId,
Content = JToken.FromObject(new { deviceInterfaces })
};
SendMessageObject(message);
}
private void HandleUserCode(JToken content, Action<string, string> action = null)
{
var code = content["userCode"];
JToken qrChecksum;
try
{
qrChecksum = content.SelectToken("qrChecksum", false);
}
catch
{
qrChecksum = new JValue(string.Empty);
}
if (code == null)
{
return;
}
if (action == null)
{
foreach (var bridge in _roomBridges)
{
bridge.SetUserCode(code.Value<string>(), qrChecksum.Value<string>());
}
return;
}
action(code.Value<string>(), qrChecksum.Value<string>());
}
/// <summary>
/// Enqueue an incoming message for processing
/// </summary>
public void HandleClientMessage(string message)
{
_receiveQueue.Enqueue(new ProcessStringMessage(message, ParseStreamRx));
}
private void ParseStreamRx(string messageText)
{
if (string.IsNullOrEmpty(messageText))
{
return;
}
if (!messageText.Contains("/system/heartbeat"))
{
this.LogDebug(
"Message RX: {messageText}",
messageText
);
}
try
{
var message = JsonConvert.DeserializeObject<MobileControlMessage>(messageText);
switch (message.Type)
{
case "hello":
SendInitialMessage();
break;
case "/system/heartbeat":
HandleHeartBeat(message.Content);
break;
case "/system/userCode":
HandleUserCode(message.Content);
break;
case "/system/clientJoined":
HandleClientJoined(message.Content);
break;
case "/system/batchDeviceFullStatus":
HandleBatchDeviceFullStatus(message.ClientId, message.Content);
break;
case "/system/reboot":
SystemMonitorController.ProcessorReboot();
break;
case "/system/programReset":
SystemMonitorController.ProgramReset(InitialParametersClass.ApplicationNumber);
break;
case "raw":
var wrapper = message.Content.ToObject<DeviceActionWrapper>();
DeviceJsonApi.DoDeviceAction(wrapper);
break;
case "close":
this.LogDebug("Received close message from server");
break;
default:
// Incoming message example
// /room/roomA/status
// /room/roomAB/status
// ActionDictionary Keys example
// /room/roomA
// /room/roomAB
// Can't do direct comparison because it will match /room/roomA with /room/roomA/xxx instead of /room/roomAB/xxx
var handlers = _actionDictionary.Where(kv => message.Type.StartsWith(kv.Key + "/")).SelectMany(kv => kv.Value).ToList(); // adds trailing slash to ensure above case is handled
if (handlers.Count == 0)
{
this.LogInformation("-- Warning: Incoming message has no registered handler {type}", message.Type);
break;
}
foreach (var handler in handlers)
{
Task.Run(() =>
{
try
{
handler.Action(message.Type, message.ClientId, message.Content);
}
catch (Exception ex)
{
this.LogError(
"Exception in handler for message type {type}, ClientId {clientId}",
message.Type,
message.ClientId
);
this.LogDebug(ex, "Stack Trace: ");
}
}).ContinueWith(task =>
{
if (task.IsFaulted && task.Exception != null)
{
this.LogError(
"Unhandled exception in Task for message type {type}, ClientId {clientId}",
message.Type,
message.ClientId
);
this.LogDebug(task.Exception.GetBaseException(), "Stack Trace: ");
}
}, TaskContinuationOptions.OnlyOnFaulted);
}
break;
}
}
catch (Exception err)
{
this.LogException(
err,
"Unable to parse {message}",
messageText
);
}
}
private void TestHttpRequest(string s)
{
{
s = s.Trim();
if (string.IsNullOrEmpty(s))
{
PrintTestHttpRequestUsage();
return;
}
var tokens = s.Split(' ');
if (tokens.Length < 2)
{
CrestronConsole.ConsoleCommandResponse("Too few paramaters\r");
PrintTestHttpRequestUsage();
return;
}
try
{
var url = tokens[1];
switch (tokens[0].ToLower())
{
case "get":
{
var resp = new HttpClient().Get(url);
CrestronConsole.ConsoleCommandResponse("RESPONSE:\r{0}\r\r", resp);
}
break;
case "post":
{
var resp = new HttpClient().Post(url, new byte[] { });
CrestronConsole.ConsoleCommandResponse("RESPONSE:\r{0}\r\r", resp);
}
break;
default:
CrestronConsole.ConsoleCommandResponse("Only get or post supported\r");
PrintTestHttpRequestUsage();
break;
}
}
catch (HttpException e)
{
CrestronConsole.ConsoleCommandResponse("Exception in request:\r");
CrestronConsole.ConsoleCommandResponse(
"Response URL: {0}\r",
e.Response.ResponseUrl
);
CrestronConsole.ConsoleCommandResponse(
"Response Error Code: {0}\r",
e.Response.Code
);
CrestronConsole.ConsoleCommandResponse(
"Response body: {0}\r",
e.Response.ContentString
);
}
}
}
private void PrintTestHttpRequestUsage()
{
CrestronConsole.ConsoleCommandResponse("Usage: mobilehttprequest:N get/post url\r");
}
}
}