Compare commits

...

10 Commits

Author SHA1 Message Date
Erik Meyer
a1029cd7c7 fix: add param info, clean up trailing whitespace and minor formatting 2026-01-27 14:39:50 -05:00
Erik Meyer
b1a5136ec6 fix: remove buildFile.txt 2026-01-27 13:53:26 -05:00
Erik Meyer
a7c4e2fd60 feat: complete XML documentation in PD Core 2026-01-27 13:22:27 -05:00
Neil Dorin
4fa7a42330 Merge pull request #1376 from PepperDash/client-id-issues
fix: handle subsequent join calls and clientid/websocket client mismatches
2026-01-21 14:59:22 -07:00
Andrew Welker
9bad3ae21b fix: handle subsequent join calls and clientid/websocket client mismatches 2026-01-21 15:20:27 -06:00
Nick Genovese
3fb30d5561 Merge pull request #1373 from PepperDash/matrix-routing-isonline
Multiple fixes
2025-12-31 15:19:31 -05:00
Andrew Welker
57cd77f019 fix: implement IKeyName for DspControlPoint 2025-12-31 14:04:04 -06:00
Andrew Welker
7f2bb078c8 fix: revert prop name to inUpPosition for screenlift messenger
- refactor volume interfaces into separate files
- IBasicVolumeControl implements IKeyName
2025-12-31 12:20:40 -06:00
Andrew Welker
316bb849b4 fix: update matrix routing inputs if endpoint online status changes 2025-12-30 16:58:36 -06:00
Andrew Welker
a983e2c87f fix: save config only when values change 2025-12-30 14:34:11 -06:00
31 changed files with 404 additions and 205 deletions

13
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.2.4",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}

View File

@@ -15,23 +15,23 @@ namespace PepperDash.Core
/// Unique Key
/// </summary>
public string Key { get; protected set; }
/// <summary>
/// Gets or sets the Name
/// </summary>
/// <summary>
/// Gets or sets the Name
/// </summary>
public string Name { get; protected set; }
/// <summary>
///
/// Gets or sets a value indicating whether the device is enabled
/// </summary>
public bool Enabled { get; protected set; }
/// <summary>
/// A place to store reference to the original config object, if any. These values should
/// NOT be used as properties on the device as they are all publicly-settable values.
/// </summary>
// /// <summary>
// /// A place to store reference to the original config object, if any. These values should
// /// NOT be used as properties on the device as they are all publicly-settable values.
// /// </summary>
//public DeviceConfig Config { get; private set; }
/// <summary>
/// Helper method to check if Config exists
/// </summary>
// /// <summary>
// /// Helper method to check if Config exists
// /// </summary>
//public bool HasConfig { get { return Config != null; } }
List<Action> _PreActivationActions;

View File

@@ -41,6 +41,9 @@ namespace PepperDash.Core
CrestronConsole.PrintLine(message);
}
/// <summary>
/// Constructor for DebugConsoleSink
/// </summary>
public DebugConsoleSink(ITextFormatter formatProvider )
{
_textFormatter = formatProvider ?? new JsonFormatter();
@@ -48,6 +51,9 @@ namespace PepperDash.Core
}
/// <summary>
/// Provides extension methods for DebugConsoleSink
/// </summary>
public static class DebugConsoleSinkExtensions
{
/// <summary>

View File

@@ -27,6 +27,9 @@ namespace PepperDash.Core.Logging
CrestronLogger.WriteToLog(message, (uint)logEvent.Level);
}
/// <summary>
/// Constructor for DebugCrestronLoggerSink
/// </summary>
public DebugCrestronLoggerSink()
{
CrestronLogger.Initialize(1, LoggerModeEnum.RM);

View File

@@ -63,6 +63,10 @@ namespace PepperDash.Core.Logging
handler(message);
}
/// <summary>
/// Constructor for DebugErrorLogSink
/// </summary>
/// <param name="formatter">text formatter for log output</param>
public DebugErrorLogSink(ITextFormatter formatter = null)
{
_formatter = formatter;

View File

@@ -4,6 +4,9 @@ using Log = PepperDash.Core.Debug;
namespace PepperDash.Core.Logging
{
/// <summary>
/// Provides extension methods for logging on IKeyed objects
/// </summary>
public static class DebugExtensions
{
/// <summary>

View File

@@ -32,6 +32,9 @@ namespace PepperDash.Core
private const string _certificateName = "selfCres";
private const string _certificatePassword = "cres12345";
/// <summary>
/// Gets the Port
/// </summary>
public int Port
{ get
{
@@ -41,6 +44,9 @@ namespace PepperDash.Core
}
}
/// <summary>
/// Gets the Url
/// </summary>
public string Url
{
get
@@ -58,6 +64,11 @@ namespace PepperDash.Core
private readonly ITextFormatter _textFormatter;
/// <summary>
/// Constructor for DebugWebsocketSink
/// </summary>
/// <param name="formatProvider">text formatter for log output</param>
public DebugWebsocketSink(ITextFormatter formatProvider)
{
@@ -217,6 +228,9 @@ namespace PepperDash.Core
}
}
/// <summary>
/// Provides extension methods for DebugWebsocketSink
/// </summary>
public static class DebugWebsocketSinkExtensions
{
/// <summary>
@@ -237,6 +251,9 @@ namespace PepperDash.Core
{
private DateTime _connectionTime;
/// <summary>
/// Gets the ConnectedDuration
/// </summary>
public TimeSpan ConnectedDuration
{
get
@@ -252,11 +269,17 @@ namespace PepperDash.Core
}
}
/// <summary>
/// Constructor for DebugClient
/// </summary>
public DebugClient()
{
Debug.Console(0, "DebugClient Created");
}
/// <summary>
/// OnOpen method
/// </summary>
protected override void OnOpen()
{
base.OnOpen();
@@ -267,6 +290,9 @@ namespace PepperDash.Core
_connectionTime = DateTime.Now;
}
/// <summary>
/// OnMessage method
/// </summary>
protected override void OnMessage(MessageEventArgs e)
{
base.OnMessage(e);
@@ -274,6 +300,9 @@ namespace PepperDash.Core
Debug.Console(0, "WebSocket UiClient Message: {0}", e.Data);
}
/// <summary>
/// OnClose method
/// </summary>
protected override void OnClose(CloseEventArgs e)
{
base.OnClose(e);
@@ -282,6 +311,9 @@ namespace PepperDash.Core
}
/// <summary>
/// OnError method
/// </summary>
protected override void OnError(WebSocketSharp.ErrorEventArgs e)
{
base.OnError(e);

View File

@@ -5,9 +5,16 @@ using System.Threading.Tasks;
namespace PepperDash.Core.Web.RequestHandlers
{
/// <summary>
/// CWS Base Async Handler, implements IHttpCwsHandler
/// </summary>
public abstract class WebApiBaseRequestAsyncHandler:IHttpCwsHandler
{
private readonly Dictionary<string, Func<HttpCwsContext, Task>> _handlers;
/// <summary>
/// Indicates whether CORS is enabled
/// </summary>
protected readonly bool EnableCors;
/// <summary>

View File

@@ -10,6 +10,10 @@ namespace PepperDash.Core.Web.RequestHandlers
public abstract class WebApiBaseRequestHandler : IHttpCwsHandler
{
private readonly Dictionary<string, Action<HttpCwsContext>> _handlers;
/// <summary>
/// Indicates whether CORS is enabled
/// </summary>
protected readonly bool EnableCors;
/// <summary>

View File

@@ -45,9 +45,9 @@ namespace PepperDash.Core.Web
/// </summary>
public bool IsRegistered { get; private set; }
/// <summary>
/// Http request handler
/// </summary>
// /// <summary>
// /// Http request handler
// /// </summary>
//public IHttpCwsHandler HttpRequestHandler
//{
// get { return _server.HttpRequestHandler; }
@@ -58,9 +58,9 @@ namespace PepperDash.Core.Web
// }
//}
/// <summary>
/// Received request event handler
/// </summary>
// /// <summary>
// /// Received request event handler
// /// </summary>
//public event EventHandler<HttpCwsRequestEventArgs> ReceivedRequestEvent
//{
// add { _server.ReceivedRequestEvent += new HttpCwsRequestEventHandler(value); }

View File

@@ -0,0 +1,10 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines minimum functionality for an audio zone
/// </summary>
public interface IAudioZone : IBasicVolumeWithFeedback
{
void SelectInput(ushort input);
}
}

View File

@@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Identifies a device that contains audio zones
/// </summary>
public interface IAudioZones : IRouting
{
Dictionary<uint, IAudioZone> Zone { get; }
}
}

View File

@@ -0,0 +1,27 @@
using PepperDash.Core;
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines minimal volume and mute control methods
/// </summary>
public interface IBasicVolumeControls : IKeyName
{
/// <summary>
/// Increases the volume
/// </summary>
/// <param name="pressRelease">Indicates whether the volume change is a press and hold action</param>
void VolumeUp(bool pressRelease);
/// <summary>
/// Decreases the volume
/// </summary>
/// <param name="pressRelease">Indicates whether the volume change is a press and hold action</param>
void VolumeDown(bool pressRelease);
/// <summary>
/// Toggles the mute state
/// </summary>
void MuteToggle();
}
}

View File

@@ -0,0 +1,14 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines the contract for IBasicVolumeWithFeedback
/// </summary>
public interface IBasicVolumeWithFeedback : IBasicVolumeControls
{
BoolFeedback MuteFeedback { get; }
void MuteOn();
void MuteOff();
void SetVolume(ushort level);
IntFeedback VolumeLevelFeedback { get; }
}
}

View File

@@ -0,0 +1,12 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines the contract for IBasicVolumeWithFeedbackAdvanced
/// </summary>
public interface IBasicVolumeWithFeedbackAdvanced : IBasicVolumeWithFeedback
{
int RawVolumeLevel { get; }
eVolumeLevelUnits Units { get; }
}
}

View File

@@ -0,0 +1,41 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines the contract for IFullAudioSettings
/// </summary>
public interface IFullAudioSettings : IBasicVolumeWithFeedback
{
void SetBalance(ushort level);
void BalanceLeft(bool pressRelease);
void BalanceRight(bool pressRelease);
void SetBass(ushort level);
void BassUp(bool pressRelease);
void BassDown(bool pressRelease);
void SetTreble(ushort level);
void TrebleUp(bool pressRelease);
void TrebleDown(bool pressRelease);
bool hasMaxVolume { get; }
void SetMaxVolume(ushort level);
void MaxVolumeUp(bool pressRelease);
void MaxVolumeDown(bool pressRelease);
bool hasDefaultVolume { get; }
void SetDefaultVolume(ushort level);
void DefaultVolumeUp(bool pressRelease);
void DefaultVolumeDown(bool pressRelease);
void LoudnessToggle();
void MonoToggle();
BoolFeedback LoudnessFeedback { get; }
BoolFeedback MonoFeedback { get; }
IntFeedback BalanceFeedback { get; }
IntFeedback BassFeedback { get; }
IntFeedback TrebleFeedback { get; }
IntFeedback MaxVolumeFeedback { get; }
IntFeedback DefaultVolumeFeedback { get; }
}
}

View File

@@ -0,0 +1,17 @@
using System;
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines the contract for IHasCurrentVolumeControls
/// </summary>
public interface IHasCurrentVolumeControls
{
IBasicVolumeControls CurrentVolumeControls { get; }
event EventHandler<VolumeDeviceChangeEventArgs> CurrentVolumeDeviceChange;
void SetDefaultLevels();
bool ZeroVolumeWhenSwtichingVolumeDevices { get; }
}
}

View File

@@ -0,0 +1,10 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines basic mute control methods
/// </summary>
public interface IHasMuteControl
{
void MuteToggle();
}
}

View File

@@ -0,0 +1,12 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines mute control methods and properties with feedback
/// </summary>
public interface IHasMuteControlWithFeedback : IHasMuteControl
{
BoolFeedback MuteFeedback { get; }
void MuteOn();
void MuteOff();
}
}

View File

@@ -0,0 +1,11 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines the contract for IHasVolumeControl
/// </summary>
public interface IHasVolumeControl
{
void VolumeUp(bool pressRelease);
void VolumeDown(bool pressRelease);
}
}

View File

@@ -0,0 +1,11 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines volume control methods and properties with feedback
/// </summary>
public interface IHasVolumeControlWithFeedback : IHasVolumeControl
{
void SetVolume(ushort level);
IntFeedback VolumeLevelFeedback { get; }
}
}

View File

@@ -0,0 +1,10 @@
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines the contract for IHasVolumeDevice
/// </summary>
public interface IHasVolumeDevice
{
IBasicVolumeControls VolumeDevice { get; }
}
}

View File

@@ -0,0 +1,10 @@
namespace PepperDash.Essentials.Core
{
public enum eVolumeLevelUnits
{
Decibels,
Percent,
Relative,
Absolute
}
}

View File

@@ -1,161 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Crestron.SimplSharp;
namespace PepperDash.Essentials.Core
{
/// <summary>
/// Defines minimal volume and mute control methods
/// </summary>
public interface IBasicVolumeControls
{
void VolumeUp(bool pressRelease);
void VolumeDown(bool pressRelease);
void MuteToggle();
}
/// <summary>
/// Defines the contract for IHasVolumeControl
/// </summary>
public interface IHasVolumeControl
{
void VolumeUp(bool pressRelease);
void VolumeDown(bool pressRelease);
}
/// <summary>
/// Defines volume control methods and properties with feedback
/// </summary>
public interface IHasVolumeControlWithFeedback : IHasVolumeControl
{
void SetVolume(ushort level);
IntFeedback VolumeLevelFeedback { get; }
}
/// <summary>
/// Defines basic mute control methods
/// </summary>
public interface IHasMuteControl
{
void MuteToggle();
}
/// <summary>
/// Defines mute control methods and properties with feedback
/// </summary>
public interface IHasMuteControlWithFeedback : IHasMuteControl
{
BoolFeedback MuteFeedback { get; }
void MuteOn();
void MuteOff();
}
/// <summary>
/// Defines the contract for IBasicVolumeWithFeedback
/// </summary>
public interface IBasicVolumeWithFeedback : IBasicVolumeControls
{
BoolFeedback MuteFeedback { get; }
void MuteOn();
void MuteOff();
void SetVolume(ushort level);
IntFeedback VolumeLevelFeedback { get; }
}
/// <summary>
/// Defines the contract for IBasicVolumeWithFeedbackAdvanced
/// </summary>
public interface IBasicVolumeWithFeedbackAdvanced : IBasicVolumeWithFeedback
{
int RawVolumeLevel { get; }
eVolumeLevelUnits Units { get; }
}
public enum eVolumeLevelUnits
{
Decibels,
Percent,
Relative,
Absolute
}
/// <summary>
/// Defines the contract for IHasCurrentVolumeControls
/// </summary>
public interface IHasCurrentVolumeControls
{
IBasicVolumeControls CurrentVolumeControls { get; }
event EventHandler<VolumeDeviceChangeEventArgs> CurrentVolumeDeviceChange;
void SetDefaultLevels();
bool ZeroVolumeWhenSwtichingVolumeDevices { get; }
}
/// <summary>
/// Defines the contract for IFullAudioSettings
/// </summary>
public interface IFullAudioSettings : IBasicVolumeWithFeedback
{
void SetBalance(ushort level);
void BalanceLeft(bool pressRelease);
void BalanceRight(bool pressRelease);
void SetBass(ushort level);
void BassUp(bool pressRelease);
void BassDown(bool pressRelease);
void SetTreble(ushort level);
void TrebleUp(bool pressRelease);
void TrebleDown(bool pressRelease);
bool hasMaxVolume { get; }
void SetMaxVolume(ushort level);
void MaxVolumeUp(bool pressRelease);
void MaxVolumeDown(bool pressRelease);
bool hasDefaultVolume { get; }
void SetDefaultVolume(ushort level);
void DefaultVolumeUp(bool pressRelease);
void DefaultVolumeDown(bool pressRelease);
void LoudnessToggle();
void MonoToggle();
BoolFeedback LoudnessFeedback { get; }
BoolFeedback MonoFeedback { get; }
IntFeedback BalanceFeedback { get; }
IntFeedback BassFeedback { get; }
IntFeedback TrebleFeedback { get; }
IntFeedback MaxVolumeFeedback { get; }
IntFeedback DefaultVolumeFeedback { get; }
}
/// <summary>
/// Defines the contract for IHasVolumeDevice
/// </summary>
public interface IHasVolumeDevice
{
IBasicVolumeControls VolumeDevice { get; }
}
/// <summary>
/// Identifies a device that contains audio zones
/// </summary>
public interface IAudioZones : IRouting
{
Dictionary<uint, IAudioZone> Zone { get; }
}
/// <summary>
/// Defines minimum functionality for an audio zone
/// </summary>
public interface IAudioZone : IBasicVolumeWithFeedback
{
void SelectInput(ushort input);
}
}

View File

@@ -100,12 +100,15 @@ namespace PepperDash.Essentials.Core.Fusion
var roomConfig = reconfigurable.Config;
var updateConfig = false;
// Set the room name
if (!string.IsNullOrEmpty(roomInfo.Name) && useFusionRoomName)
{
Debug.LogDebug("Current Room Name: {currentName}. New Room Name: {fusionName}", roomConfig.Name, roomInfo.Name);
// Set the name in config
roomConfig.Name = roomInfo.Name;
updateConfig = true;
Debug.LogDebug("Room Name Successfully Changed.");
}
@@ -115,9 +118,13 @@ namespace PepperDash.Essentials.Core.Fusion
if (helpMessage != null)
{
roomConfig.Properties["helpMessage"] = helpMessage.CustomFieldValue;
updateConfig = true;
}
reconfigurable.SetConfig(roomConfig);
if (updateConfig)
{
reconfigurable.SetConfig(roomConfig);
}
}
catch (Exception e)
{

View File

@@ -61,13 +61,18 @@ namespace PepperDash.Essentials.Devices.Common.DSP
/// <summary>
/// Base class for DSP control points
/// </summary>
public abstract class DspControlPoint : IKeyed
public abstract class DspControlPoint : IKeyName
{
/// <summary>
/// Gets or sets the Key
/// </summary>
public string Key { get; }
/// <summary>
/// Gets or sets the Name
/// </summary>
public string Name { get; private set; }
/// <summary>
/// Initializes a new instance of the DspControlPoint class
/// </summary>

View File

@@ -22,7 +22,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// <param name="messagePath">The message path.</param>
/// <param name="device">The device.</param>
public DeviceVolumeMessenger(string key, string messagePath, IBasicVolumeControls device)
: base(key, messagePath, device as IKeyName)
: base(key, messagePath, device)
{
this.device = device;
}

View File

@@ -62,6 +62,14 @@ namespace PepperDash.Essentials.AppServer.Messengers
inputs = matrixDevice.InputSlots.ToDictionary(kvp => kvp.Key, kvp => new RoutingInput(kvp.Value))
}));
};
inputSlot.IsOnline.OutputChange += (sender, args) =>
{
PostStatusMessage(JToken.FromObject(new
{
inputs = matrixDevice.InputSlots.ToDictionary(kvp => kvp.Key, kvp => new RoutingInput(kvp.Value))
}));
};
}
}

View File

@@ -85,7 +85,7 @@ namespace PepperDash.Essentials.AppServer.Messengers
/// <summary>
/// Gets or sets the InUpPosition
/// </summary>
[JsonProperty("isInUpPosition", NullValueHandling = NullValueHandling.Ignore)]
[JsonProperty("inUpPosition", NullValueHandling = NullValueHandling.Ignore)]
public bool? InUpPosition { get; set; }
/// <summary>

View File

@@ -69,10 +69,11 @@ namespace PepperDash.Essentials.WebSocketServer
private readonly ConcurrentDictionary<string, string> pendingClientRegistrations = new ConcurrentDictionary<string, string>();
/// <summary>
/// Stores queues of pending client IDs per token for legacy clients (FIFO)
/// This ensures thread-safety when multiple legacy clients use the same token
/// Stores pending client registrations with timestamp for legacy clients
/// Key is token, Value is list of (clientId, timestamp) tuples
/// Most recent registration is used to handle duplicate join requests
/// </summary>
private readonly ConcurrentDictionary<string, ConcurrentQueue<string>> legacyClientIdQueues = new ConcurrentDictionary<string, ConcurrentQueue<string>>();
private readonly ConcurrentDictionary<string, ConcurrentBag<(string clientId, DateTime timestamp)>> legacyClientRegistrations = new ConcurrentDictionary<string, ConcurrentBag<(string, DateTime)>>();
/// <summary>
/// Gets the collection of UI clients
@@ -736,15 +737,23 @@ namespace PepperDash.Essentials.WebSocketServer
private UiClient BuildUiClient(string roomKey, JoinToken token, string key)
{
// Dequeue the next clientId for legacy client support (FIFO per token)
// Get the most recent unused clientId for this token (legacy support)
// New clients will override this ID in OnOpen with the validated query parameter value
var clientId = "pending";
if (legacyClientIdQueues.TryGetValue(key, out var queue) && queue.TryDequeue(out var dequeuedId))
if (legacyClientRegistrations.TryGetValue(key, out var registrations))
{
clientId = dequeuedId;
this.LogVerbose("Dequeued legacy clientId {clientId} for token {token}", clientId, key);
// Get most recent registration
var sorted = registrations.OrderByDescending(r => r.timestamp).ToList();
if (sorted.Any())
{
clientId = sorted.First().clientId;
// Remove it from the bag
var newBag = new ConcurrentBag<(string, DateTime)>(sorted.Skip(1));
legacyClientRegistrations.TryUpdate(key, newBag, registrations);
this.LogVerbose("Assigned most recent legacy clientId {clientId} for token {token}", clientId, key);
}
}
var c = new UiClient($"uiclient-{key}-{roomKey}-{clientId}", clientId, token.Token, token.TouchpanelKey);
this.LogInformation("Constructing UiClient with key {key} and temporary ID (will be set from query param)", key);
c.Controller = _parent;
@@ -753,8 +762,8 @@ namespace PepperDash.Essentials.WebSocketServer
c.Server = this; // Give UiClient access to server for ID registration
// Don't add to uiClients yet - will be added in OnOpen after ID is set from query param
c.ConnectionClosed += (o, a) =>
c.ConnectionClosed += (o, a) =>
{
uiClients.TryRemove(a.ClientId, out _);
// Clean up any pending registrations for this token
@@ -765,11 +774,11 @@ namespace PepperDash.Essentials.WebSocketServer
{
pendingClientRegistrations.TryRemove(k, out _);
}
// Clean up legacy queue if empty
if (legacyClientIdQueues.TryGetValue(key, out var legacyQueue) && legacyQueue.IsEmpty)
// Clean up legacy registrations if empty
if (legacyClientRegistrations.TryGetValue(key, out var legacyBag) && legacyBag.IsEmpty)
{
legacyClientIdQueues.TryRemove(key, out _);
legacyClientRegistrations.TryRemove(key, out _);
}
};
return c;
@@ -785,7 +794,7 @@ namespace PepperDash.Essentials.WebSocketServer
public bool RegisterUiClient(UiClient client, string clientId, string tokenKey)
{
var registrationKey = $"{tokenKey}-{clientId}";
// Verify this clientId was generated during a join request for this token
if (!pendingClientRegistrations.TryRemove(registrationKey, out _))
{
@@ -799,11 +808,63 @@ namespace PepperDash.Essentials.WebSocketServer
this.LogWarning("Replacing existing client with duplicate id {id}", id);
return client;
});
this.LogInformation("Successfully registered UiClient with ID {clientId} for token {token}", clientId, tokenKey);
return true;
}
/// <summary>
/// Updates a client's ID when a mismatch is detected between stored ID and message ID
/// </summary>
/// <param name="oldClientId">The current/old client ID</param>
/// <param name="newClientId">The new client ID from the message</param>
/// <param name="tokenKey">The token key for validation</param>
/// <returns>True if update successful, false otherwise</returns>
public bool UpdateClientId(string oldClientId, string newClientId, string tokenKey)
{
if (string.IsNullOrEmpty(oldClientId) || string.IsNullOrEmpty(newClientId))
{
this.LogWarning("Cannot update client ID with null or empty values");
return false;
}
if (oldClientId == newClientId)
{
return true; // No update needed
}
// Verify the new clientId was registered for this token
var registrationKey = $"{tokenKey}-{newClientId}";
if (!pendingClientRegistrations.TryRemove(registrationKey, out _))
{
this.LogWarning("Cannot update to unregistered clientId {newClientId} for token {token}", newClientId, tokenKey);
return false;
}
// Get the existing client
if (!uiClients.TryRemove(oldClientId, out var client))
{
this.LogWarning("Cannot find client with old ID {oldClientId}", oldClientId);
return false;
}
// Update the client's ID
client.UpdateId(newClientId);
// Re-add with new ID
if (!uiClients.TryAdd(newClientId, client))
{
// If add fails, try to restore old entry
uiClients.TryAdd(oldClientId, client);
client.UpdateId(oldClientId);
this.LogError("Failed to update client ID from {oldClientId} to {newClientId}", oldClientId, newClientId);
return false;
}
this.LogInformation("Successfully updated client ID from {oldClientId} to {newClientId}", oldClientId, newClientId);
return true;
}
/// <summary>
/// Registers a UiClient using legacy flow (for backwards compatibility with older clients)
/// </summary>
@@ -821,7 +882,7 @@ namespace PepperDash.Essentials.WebSocketServer
this.LogWarning("Replacing existing client with duplicate id {id} (legacy flow)", id);
return client;
});
this.LogInformation("Successfully registered UiClient with ID {clientId} using legacy flow", client.Id);
}
@@ -1133,16 +1194,17 @@ namespace PepperDash.Essentials.WebSocketServer
// Generate a client ID for this join request
var clientId = $"{Utilities.GetNextClientId()}";
var now = DateTime.UtcNow;
// Store in pending registrations for new clients that send clientId via query param
var registrationKey = $"{token}-{clientId}";
pendingClientRegistrations.TryAdd(registrationKey, clientId);
// Also enqueue for legacy clients (thread-safe FIFO per token)
var queue = legacyClientIdQueues.GetOrAdd(token, _ => new ConcurrentQueue<string>());
queue.Enqueue(clientId);
this.LogVerbose("Assigning ClientId: {clientId} for token: {token}", clientId, token);
// For legacy clients, store with timestamp instead of FIFO queue
var legacyBag = legacyClientRegistrations.GetOrAdd(token, _ => new ConcurrentBag<(string, DateTime)>());
legacyBag.Add((clientId, now));
this.LogVerbose("Assigning ClientId: {clientId} for token: {token} at {timestamp}", clientId, token, now);
// Construct WebSocket URL with clientId query parameter
var wsProtocol = "ws";

View File

@@ -26,6 +26,15 @@ namespace PepperDash.Essentials.WebSocketServer
/// </summary>
public string Id { get; private set; }
/// <summary>
/// Updates the client ID - only accessible from within the assembly (e.g., by the server)
/// </summary>
/// <param name="newId">The new client ID</param>
internal void UpdateId(string newId)
{
Id = newId;
}
/// <summary>
/// Token associated with this client
/// </summary>