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.
This commit is contained in:
Andrew Welker
2025-09-26 21:31:54 -05:00
parent 087d0a1149
commit bb694b4200
6 changed files with 239 additions and 9 deletions

View File

@@ -0,0 +1,23 @@
using PepperDash.Core;
namespace PepperDash.Essentials.Core.DeviceTypeInterfaces
{
/// <summary>
/// Defines the contract for IMobileControlMessenger
/// </summary>
public interface IMobileControlMessengerWithSubscriptions : IMobileControlMessenger
{
/// <summary>
/// Unsubscribe a client from this messenger
/// </summary>
/// <param name="clientId"></param>
void UnsubscribeClient(string clientId);
/// <summary>
/// Register this messenger with the AppServerController
/// </summary>
/// <param name="appServerController">parent for this messenger</param>
/// <param name="enableMessengerSubscriptions">Enable messenger subscriptions</param>
void RegisterWithAppServer(IMobileControl appServerController, bool enableMessengerSubscriptions);
}
}

View File

@@ -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
/// <summary>
/// Provides a messaging bridge
/// </summary>
public abstract class MessengerBase : EssentialsDevice, IMobileControlMessenger
public abstract class MessengerBase : EssentialsDevice, IMobileControlMessengerWithSubscriptions
{
/// <summary>
/// The device this messenger is associated with
/// </summary>
protected IKeyName _device;
/// <summary>
/// Enable subscriptions
/// </summary>
protected bool enableMessengerSubscriptions;
/// <summary>
/// List of clients subscribed to this messenger
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
protected HashSet<string> SubscriberIds = new HashSet<string>();
private readonly List<string> _deviceInterfaces;
private readonly Dictionary<string, Action<string, JToken>> _actions = new Dictionary<string, Action<string, JToken>>();
@@ -93,6 +106,21 @@ namespace PepperDash.Essentials.AppServer.Messengers
RegisterActions();
}
/// <summary>
/// Register this messenger with appserver controller
/// </summary>
/// <param name="appServerController">Parent controller for this messenger</param>
/// <param name="enableMessengerSubscriptions">Enable subscriptions</param>
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
}
/// <summary>
/// Add client to the susbscription list for unsolicited feedback
/// </summary>
/// <param name="clientId">Client ID to add</param>
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);
}
/// <summary>
/// Remove a client from the subscription list
/// </summary>
/// <param name="clientId">Client ID to remove</param>
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);
}
/// <summary>
/// Helper for posting status message
/// </summary>
@@ -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: ");
}
}

View File

@@ -38,6 +38,12 @@ namespace PepperDash.Essentials
/// </summary>
[JsonProperty("enableApiServer")]
public bool EnableApiServer { get; set; } = true;
/// <summary>
/// Enable subscriptions for Messengers
/// </summary>
[JsonProperty("enableMessengerSubscriptions")]
public bool EnableMessengerSubscriptions { get; set; }
}
/// <summary>
@@ -78,6 +84,15 @@ namespace PepperDash.Essentials
[JsonProperty("csLanUiDeviceKeys")]
public List<string> CSLanUiDeviceKeys { get; set; }
/// <summary>
/// Get or set the Secure property
/// </summary>
/// <remarks>
/// Indicates whether the connection is secure (HTTPS).
/// </remarks>
[JsonProperty("Secure")]
public bool Secure { get; set; }
/// <summary>
/// Initializes a new instance of the MobileControlDirectServerPropertiesConfig class.
/// </summary>

View File

@@ -54,7 +54,10 @@ namespace PepperDash.Essentials
StringComparer.InvariantCultureIgnoreCase
);
public Dictionary<string, List<IMobileControlAction>> ActionDictionary => _actionDictionary;
/// <summary>
/// Actions
/// </summary>
public ReadOnlyDictionary<string, List<IMobileControlAction>> ActionDictionary => new ReadOnlyDictionary<string, List<IMobileControlAction>>(_actionDictionary);
private readonly GenericQueue _receiveQueue;
private readonly List<MobileControlBridgeBase> _roomBridges =
@@ -66,6 +69,16 @@ namespace PepperDash.Essentials
private readonly Dictionary<string, IMobileControlMessenger> _defaultMessengers =
new Dictionary<string, IMobileControlMessenger>();
/// <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>
/// Get the default messengers
/// </summary>
public ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions> DefaultMessengers => new ReadOnlyDictionary<string, IMobileControlMessengerWithSubscriptions>(_defaultMessengers.Values.OfType<IMobileControlMessengerWithSubscriptions>().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);
}

View File

@@ -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";
/// <summary>
/// Where the key is the join token and the value is the room key
/// </summary>
@@ -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;

View File

@@ -22,14 +22,29 @@ namespace PepperDash.Essentials.WebSocketServer
/// <inheritdoc />
public string Key { get; private set; }
/// <summary>
/// Gets or sets the mobile control system controller that handles this client's messages
/// </summary>
public MobileControlSystemController Controller { get; set; }
/// <summary>
/// Gets or sets the room key that this client is associated with
/// </summary>
public string RoomKey { get; set; }
/// <summary>
/// The unique identifier for this client instance
/// </summary>
private string _clientId;
/// <summary>
/// The timestamp when this client connection was established
/// </summary>
private DateTime _connectionTime;
/// <summary>
/// Gets the duration that this client has been connected. Returns zero if not currently connected.
/// </summary>
public TimeSpan ConnectedDuration
{
get
@@ -45,6 +60,10 @@ namespace PepperDash.Essentials.WebSocketServer
}
}
/// <summary>
/// Initializes a new instance of the UiClient class with the specified key
/// </summary>
/// <param name="key">The unique key to identify this client</param>
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
}
/// <summary>
/// Handles the UserCodeChanged event from a room bridge and sends the updated user code to the client
/// </summary>
/// <param name="sender">The room bridge that raised the event</param>
/// <param name="e">Event arguments</param>
private void Bridge_UserCodeChanged(object sender, EventArgs e)
{
SendUserCodeToClient((MobileControlEssentialsRoomBridge)sender, _clientId);
}
/// <summary>
/// Sends the current user code and QR code URL to the specified client
/// </summary>
/// <param name="bridge">The room bridge containing the user code information</param>
/// <param name="clientId">The ID of the client to send the information to</param>
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);
}
}
/// <inheritdoc />