From bb694b4200e0e4ffc1f7124200318684e0bc7c37 Mon Sep 17 00:00:00 2001 From: Andrew Welker Date: Fri, 26 Sep 2025 21:31:54 -0500 Subject: [PATCH] feat: enable subscription logic for messengers In order to help control traffic over the websocket, a subscription feature has been added: * A config option, `enableMessengerSubscriptions` has been added * When true, the MessengerBase class will assume that any message sent using the `PostStatusMessage` that has a valid client ID wants to send any subsequent unsolicited updates to that same client * The client's ID will be added a list of subscribed client IDs * Any subsequent messages sent using the `PostStatusMessage` methods that have a null clientId will ONLY be sent to subscribed clients * When a client disconnects, it will be removed from the list of subscribed clients This should cut down drastically on the traffic to the UI, especially when combined with requesting partial status updates from a device rather than the entire state. --- ...MobileControlMessengerWithSubscriptions.cs | 23 ++++ .../Messengers/MessengerBase.cs | 105 +++++++++++++++++- .../MobileControlConfig.cs | 15 +++ .../MobileControlSystemController.cs | 24 +++- .../MobileControlWebsocketServer.cs | 42 ++++++- .../WebSocketServer/UiClient.cs | 39 +++++++ 6 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs diff --git a/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs new file mode 100644 index 00000000..887f1789 --- /dev/null +++ b/src/PepperDash.Essentials.Core/DeviceTypeInterfaces/IMobileControlMessengerWithSubscriptions.cs @@ -0,0 +1,23 @@ +using PepperDash.Core; + +namespace PepperDash.Essentials.Core.DeviceTypeInterfaces +{ + /// + /// Defines the contract for IMobileControlMessenger + /// + public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger + { + /// + /// Unsubscribe a client from this messenger + /// + /// + void UnsubscribeClient(string clientId); + + /// + /// Register this messenger with the AppServerController + /// + /// parent for this messenger + /// Enable messenger subscriptions + void RegisterWithAppServer(IMobileControl appServerController, bool enableMessengerSubscriptions); + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs index 9ed9029b..a7210a13 100644 --- a/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs +++ b/src/PepperDash.Essentials.MobileControl.Messengers/Messengers/MessengerBase.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using Newtonsoft.Json; +using Crestron.SimplSharp.Net; using Newtonsoft.Json.Linq; using PepperDash.Core; using PepperDash.Core.Logging; @@ -13,13 +13,26 @@ namespace PepperDash.Essentials.AppServer.Messengers /// /// Provides a messaging bridge /// - public abstract class MessengerBase : EssentialsDevice, IMobileControlMessenger + public abstract class MessengerBase : EssentialsDevice, IMobileControlMessengerWithSubscriptions { /// /// The device this messenger is associated with /// protected IKeyName _device; + /// + /// Enable subscriptions + /// + protected bool enableMessengerSubscriptions; + + /// + /// List of clients subscribed to this messenger + /// + /// + /// Unsoliciited feedback from a device in a messenger will ONLY be sent to devices in this subscription list. When a client disconnects, it's ID will be removed from the collection. + /// + protected HashSet SubscriberIds = new HashSet(); + private readonly List _deviceInterfaces; private readonly Dictionary> _actions = new Dictionary>(); @@ -93,6 +106,21 @@ namespace PepperDash.Essentials.AppServer.Messengers RegisterActions(); } + /// + /// Register this messenger with appserver controller + /// + /// Parent controller for this messenger + /// Enable subscriptions + public void RegisterWithAppServer(IMobileControl appServerController, bool enableMessengerSubscriptions) + { + this.enableMessengerSubscriptions = enableMessengerSubscriptions; + AppServerController = appServerController ?? throw new ArgumentNullException("appServerController"); + + AppServerController.AddAction(this, HandleMessage); + + RegisterActions(); + } + private void HandleMessage(string path, string id, JToken content) { // replace base path with empty string. Should leave something like /fullStatus @@ -103,7 +131,7 @@ namespace PepperDash.Essentials.AppServer.Messengers return; } - Debug.LogMessage(Serilog.Events.LogEventLevel.Debug, "Executing action for path {path}", this, path); + this.LogDebug("Executing action for path {path}", path); action(id, content); } @@ -117,7 +145,6 @@ namespace PepperDash.Essentials.AppServer.Messengers { if (_actions.ContainsKey(path)) { - //Debug.LogMessage(Serilog.Events.LogEventLevel.Verbose, $"Messenger {Key} already has action registered at {path}", this); return; } @@ -154,6 +181,52 @@ namespace PepperDash.Essentials.AppServer.Messengers } + /// + /// Add client to the susbscription list for unsolicited feedback + /// + /// Client ID to add + protected void SubscribeClient(string clientId) + { + if (!enableMessengerSubscriptions) + { + this.LogWarning("Messenger subscriptions not enabled"); + return; + } + + if (SubscriberIds.Any(id => id == clientId)) + { + this.LogVerbose("Client {clientId} already subscribed", clientId); + return; + } + + SubscriberIds.Add(clientId); + + this.LogDebug("Client {clientId} subscribed", clientId); + } + + /// + /// Remove a client from the subscription list + /// + /// Client ID to remove + public void UnsubscribeClient(string clientId) + { + if (!enableMessengerSubscriptions) + { + this.LogWarning("Messenger subscriptions not enabled"); + return; + } + + if (!SubscriberIds.Any(i => i == clientId)) + { + this.LogVerbose("Client with ID {clientId} is not subscribed", clientId); + return; + } + + SubscriberIds.RemoveWhere((i) => i == clientId); + + this.LogInformation("Client with ID {clientId} unsubscribed", clientId); + } + /// /// Helper for posting status message /// @@ -228,11 +301,33 @@ namespace PepperDash.Essentials.AppServer.Messengers { try { + // Allow for legacy method to continue without subscriptions + if (!enableMessengerSubscriptions) + { + AppServerController?.SendMessageObject(new MobileControlMessage { Type = !string.IsNullOrEmpty(type) ? type : MessagePath, ClientId = clientId, Content = content }); + return; + } + + // handle subscription feedback + // If client is null or empty, this message is unsolicited feedback. Iterate through the subscriber list and send to all interested parties + if (string.IsNullOrEmpty(clientId)) + { + foreach (var client in SubscriberIds) + { + AppServerController?.SendMessageObject(new MobileControlMessage { Type = !string.IsNullOrEmpty(type) ? type : MessagePath, ClientId = client, Content = content }); + } + + return; + } + + SubscribeClient(clientId); + AppServerController?.SendMessageObject(new MobileControlMessage { Type = !string.IsNullOrEmpty(type) ? type : MessagePath, ClientId = clientId, Content = content }); } catch (Exception ex) { - Debug.LogMessage(ex, "Exception posting status message", this); + this.LogError("Exception posting status message: {message}", ex.Message); + this.LogDebug(ex, "Stack Trace: "); } } diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs index 6c130f6d..ec7219a3 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlConfig.cs @@ -38,6 +38,12 @@ namespace PepperDash.Essentials /// [JsonProperty("enableApiServer")] public bool EnableApiServer { get; set; } = true; + + /// + /// Enable subscriptions for Messengers + /// + [JsonProperty("enableMessengerSubscriptions")] + public bool EnableMessengerSubscriptions { get; set; } } /// @@ -78,6 +84,15 @@ namespace PepperDash.Essentials [JsonProperty("csLanUiDeviceKeys")] public List CSLanUiDeviceKeys { get; set; } + /// + /// Get or set the Secure property + /// + /// + /// Indicates whether the connection is secure (HTTPS). + /// + [JsonProperty("Secure")] + public bool Secure { get; set; } + /// /// Initializes a new instance of the MobileControlDirectServerPropertiesConfig class. /// diff --git a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs index 736bf69c..bd04b0e3 100644 --- a/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs +++ b/src/PepperDash.Essentials.MobileControl/MobileControlSystemController.cs @@ -54,7 +54,10 @@ namespace PepperDash.Essentials StringComparer.InvariantCultureIgnoreCase ); - public Dictionary> ActionDictionary => _actionDictionary; + /// + /// Actions + /// + public ReadOnlyDictionary> ActionDictionary => new ReadOnlyDictionary>(_actionDictionary); private readonly GenericQueue _receiveQueue; private readonly List _roomBridges = @@ -66,6 +69,16 @@ namespace PepperDash.Essentials private readonly Dictionary _defaultMessengers = new Dictionary(); + /// + /// Get the custom messengers with subscriptions + /// + public ReadOnlyDictionary Messengers => new ReadOnlyDictionary(_messengers.Values.OfType().ToDictionary(k => k.Key, v => v)); + + /// + /// Get the default messengers + /// + public ReadOnlyDictionary DefaultMessengers => new ReadOnlyDictionary(_defaultMessengers.Values.OfType().ToDictionary(k => k.Key, v => v)); + private readonly GenericQueue _transmitToServerQueue; private readonly GenericQueue _transmitToClientsQueue; @@ -1199,8 +1212,7 @@ namespace PepperDash.Essentials if (_initialized) { - this.LogDebug("Registering messenger {messengerKey} AFTER initialization", messenger.Key); - messenger.RegisterWithAppServer(this); + RegisterMessengerWithServer(messenger); } } @@ -1241,6 +1253,12 @@ namespace PepperDash.Essentials messenger.MessagePath ); + if (messenger is IMobileControlMessengerWithSubscriptions subMessenger) + { + subMessenger.RegisterWithAppServer(this, Config.EnableMessengerSubscriptions); + return; + } + messenger.RegisterWithAppServer(this); } diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs index f2f5003a..eb60857e 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs @@ -4,6 +4,8 @@ using System.ComponentModel; using System.IO; using System.Linq; using System.Net.Http; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; using System.Text; using Crestron.SimplSharp; using Crestron.SimplSharp.WebScripting; @@ -35,6 +37,10 @@ namespace PepperDash.Essentials.WebSocketServer private readonly string appConfigFileName = "_config.local.json"; private readonly string appConfigCsFileName = "_config.cs.json"; + private const string certificateName = "selfCres"; + + private const string certificatePassword = "cres12345"; + /// /// Where the key is the join token and the value is the room key /// @@ -260,7 +266,41 @@ namespace PepperDash.Essentials.WebSocketServer _server.OnPost += Server_OnPost; } - _server.Log.Output = (data, level) => this.LogInformation("WebSocket Server Log [{level}]: {data}", level, data); + if (_parent.Config.DirectServer.Secure) + { + this.LogInformation("Adding SSL Configuration to server"); + _server.SslConfiguration = new ServerSslConfiguration(new X509Certificate2($"\\user\\{certificateName}.pfx", certificatePassword)) + { + ClientCertificateRequired = false, + CheckCertificateRevocation = false, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls11 + }; + } + + _server.Log.Output = (data, message) => + { + switch (data.Level) + { + case LogLevel.Trace: + this.LogVerbose(data.Message); + break; + case LogLevel.Debug: + this.LogDebug(data.Message); + break; + case LogLevel.Info: + this.LogInformation(data.Message); + break; + case LogLevel.Warn: + this.LogWarning(data.Message); + break; + case LogLevel.Error: + this.LogError(data.Message); + break; + case LogLevel.Fatal: + this.LogFatal(data.Message); + break; + } + }; CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler; diff --git a/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs b/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs index d1d4524d..3cdd183b 100644 --- a/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs +++ b/src/PepperDash.Essentials.MobileControl/WebSocketServer/UiClient.cs @@ -22,14 +22,29 @@ namespace PepperDash.Essentials.WebSocketServer /// public string Key { get; private set; } + /// + /// Gets or sets the mobile control system controller that handles this client's messages + /// public MobileControlSystemController Controller { get; set; } + /// + /// Gets or sets the room key that this client is associated with + /// public string RoomKey { get; set; } + /// + /// The unique identifier for this client instance + /// private string _clientId; + /// + /// The timestamp when this client connection was established + /// private DateTime _connectionTime; + /// + /// Gets the duration that this client has been connected. Returns zero if not currently connected. + /// public TimeSpan ConnectedDuration { get @@ -45,6 +60,10 @@ namespace PepperDash.Essentials.WebSocketServer } } + /// + /// Initializes a new instance of the UiClient class with the specified key + /// + /// The unique key to identify this client public UiClient(string key) { Key = key; @@ -99,11 +118,21 @@ namespace PepperDash.Essentials.WebSocketServer // TODO: Future: Check token to see if there's already an open session using that token and reject/close the session } + /// + /// Handles the UserCodeChanged event from a room bridge and sends the updated user code to the client + /// + /// The room bridge that raised the event + /// Event arguments private void Bridge_UserCodeChanged(object sender, EventArgs e) { SendUserCodeToClient((MobileControlEssentialsRoomBridge)sender, _clientId); } + /// + /// Sends the current user code and QR code URL to the specified client + /// + /// The room bridge containing the user code information + /// The ID of the client to send the information to private void SendUserCodeToClient(MobileControlBridgeBase bridge, string clientId) { var content = new @@ -140,6 +169,16 @@ namespace PepperDash.Essentials.WebSocketServer base.OnClose(e); this.LogInformation("WebSocket UiClient Closing: {code} reason: {reason}", e.Code, e.Reason); + + foreach (var messenger in Controller.Messengers) + { + messenger.Value.UnsubscribeClient(_clientId); + } + + foreach (var messenger in Controller.DefaultMessengers) + { + messenger.Value.UnsubscribeClient(_clientId); + } } ///