Add C# bridges and coordinator service
- geviscope-bridge: GeViScope SDK REST wrapper (:7720) - gcore-bridge: G-Core SDK REST wrapper (:7721) - geviserver-bridge: GeViServer REST wrapper (:7710) - copilot-coordinator: WebSocket coordination hub (:8090) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
287
copilot-coordinator/Services/LockManager.cs
Normal file
287
copilot-coordinator/Services/LockManager.cs
Normal file
@@ -0,0 +1,287 @@
|
||||
using CopilotCoordinator.Models;
|
||||
|
||||
namespace CopilotCoordinator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Camera lock manager. Ported from legacy CameraLocksService.cs (~363 lines).
|
||||
/// In-memory state only — locks are lost on restart (same as legacy AppServer).
|
||||
/// Locks expire after configurable timeout (default 5 minutes).
|
||||
/// </summary>
|
||||
public class LockManager
|
||||
{
|
||||
private readonly SemaphoreSlim _locksLock = new(1, 1);
|
||||
private readonly Dictionary<int, CameraLock> _locks = new();
|
||||
|
||||
private readonly SemaphoreSlim _requestsLock = new(1, 1);
|
||||
private readonly Dictionary<int, List<(string KeyboardId, CameraLockPriority Priority, DateTime CreatedAt)>> _requests = new();
|
||||
|
||||
private readonly TimeSpan _expirationTimeout;
|
||||
private readonly TimeSpan _warningBefore;
|
||||
private readonly WsBroadcaster _broadcaster;
|
||||
private readonly ILogger<LockManager> _logger;
|
||||
|
||||
// Track which locks have already been warned about (to avoid repeat notifications)
|
||||
private readonly Dictionary<int, DateTime> _warnedLocks = new();
|
||||
|
||||
public LockManager(IConfiguration config, WsBroadcaster broadcaster, ILogger<LockManager> logger)
|
||||
{
|
||||
_expirationTimeout = config.GetValue("LockExpiration:Timeout", TimeSpan.FromMinutes(5));
|
||||
_warningBefore = config.GetValue("LockExpiration:WarningBefore", TimeSpan.FromMinutes(1));
|
||||
_broadcaster = broadcaster;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CameraLock>> GetAllLocks()
|
||||
{
|
||||
await _locksLock.WaitAsync();
|
||||
try { return _locks.Values.ToList(); }
|
||||
finally { _locksLock.Release(); }
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<int>> GetLockedCameraIds(string keyboardId)
|
||||
{
|
||||
await _locksLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
return _locks.Values
|
||||
.Where(l => l.OwnerName.Equals(keyboardId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(l => l.CameraId)
|
||||
.ToList();
|
||||
}
|
||||
finally { _locksLock.Release(); }
|
||||
}
|
||||
|
||||
public async Task<CameraLockResult> TryLock(int cameraId, string keyboardId, CameraLockPriority priority)
|
||||
{
|
||||
_logger.LogInformation("TryLock camera {CameraId} by {Keyboard} priority {Priority}", cameraId, keyboardId, priority);
|
||||
|
||||
await _locksLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!_locks.TryGetValue(cameraId, out var existing))
|
||||
{
|
||||
// Camera is free — acquire lock
|
||||
var now = DateTime.UtcNow;
|
||||
var newLock = new CameraLock(cameraId, priority, keyboardId, now, now + _expirationTimeout);
|
||||
_locks[cameraId] = newLock;
|
||||
|
||||
_logger.LogInformation("Camera {CameraId} locked by {Keyboard}", cameraId, keyboardId);
|
||||
await _broadcaster.Broadcast("lock_acquired", newLock);
|
||||
|
||||
// If there's a pending takeover request, notify the new owner
|
||||
await NotifyPendingTakeover(cameraId);
|
||||
|
||||
return new CameraLockResult(true, newLock);
|
||||
}
|
||||
|
||||
// Camera already locked — check if priority can take over
|
||||
if (CanTakeOver(priority, existing.Priority))
|
||||
{
|
||||
var previousOwner = existing.OwnerName;
|
||||
var now = DateTime.UtcNow;
|
||||
var newLock = new CameraLock(cameraId, priority, keyboardId, now, now + _expirationTimeout);
|
||||
_locks[cameraId] = newLock;
|
||||
|
||||
_logger.LogInformation("Camera {CameraId} taken over from {Previous} by {Keyboard}", cameraId, previousOwner, keyboardId);
|
||||
|
||||
// Notify previous owner
|
||||
await _broadcaster.SendTo(previousOwner, "lock_notification", new CameraLockNotification(
|
||||
CameraLockNotificationType.TakenOver, cameraId, keyboardId));
|
||||
|
||||
await _broadcaster.Broadcast("lock_acquired", newLock);
|
||||
return new CameraLockResult(true, newLock);
|
||||
}
|
||||
|
||||
// Cannot take over — return current lock info
|
||||
_logger.LogInformation("Camera {CameraId} lock denied for {Keyboard} — held by {Owner}", cameraId, keyboardId, existing.OwnerName);
|
||||
return new CameraLockResult(false, existing);
|
||||
}
|
||||
finally { _locksLock.Release(); }
|
||||
}
|
||||
|
||||
public async Task Unlock(int cameraId, string keyboardId)
|
||||
{
|
||||
_logger.LogInformation("Unlock camera {CameraId} by {Keyboard}", cameraId, keyboardId);
|
||||
|
||||
await _locksLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!_locks.TryGetValue(cameraId, out var existing))
|
||||
{
|
||||
_logger.LogWarning("Cannot unlock camera {CameraId} — not locked", cameraId);
|
||||
return;
|
||||
}
|
||||
|
||||
_locks.Remove(cameraId);
|
||||
_warnedLocks.Remove(cameraId);
|
||||
|
||||
_logger.LogInformation("Camera {CameraId} unlocked", cameraId);
|
||||
}
|
||||
finally { _locksLock.Release(); }
|
||||
|
||||
await _broadcaster.Broadcast("lock_released", new { cameraId });
|
||||
|
||||
// Notify the owner
|
||||
await _broadcaster.SendTo(keyboardId, "lock_notification", new CameraLockNotification(
|
||||
CameraLockNotificationType.Unlocked, cameraId, keyboardId));
|
||||
}
|
||||
|
||||
public async Task RequestTakeover(int cameraId, string keyboardId, CameraLockPriority priority)
|
||||
{
|
||||
_logger.LogInformation("Takeover requested for camera {CameraId} by {Keyboard}", cameraId, keyboardId);
|
||||
|
||||
string? currentOwner;
|
||||
await _locksLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!_locks.TryGetValue(cameraId, out var existing))
|
||||
return;
|
||||
currentOwner = existing.OwnerName;
|
||||
}
|
||||
finally { _locksLock.Release(); }
|
||||
|
||||
// Queue the request
|
||||
await _requestsLock.WaitAsync();
|
||||
bool isFirst;
|
||||
try
|
||||
{
|
||||
if (!_requests.TryGetValue(cameraId, out var list))
|
||||
{
|
||||
list = new();
|
||||
_requests[cameraId] = list;
|
||||
}
|
||||
list.Add((keyboardId, priority, DateTime.UtcNow));
|
||||
list.Sort((a, b) => a.CreatedAt.CompareTo(b.CreatedAt));
|
||||
isFirst = list.Count == 1;
|
||||
}
|
||||
finally { _requestsLock.Release(); }
|
||||
|
||||
// Only notify current owner if this is the first request (avoid spamming)
|
||||
if (isFirst)
|
||||
{
|
||||
await _broadcaster.SendTo(currentOwner, "lock_notification", new CameraLockNotification(
|
||||
CameraLockNotificationType.ConfirmTakeOver, cameraId, keyboardId));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ConfirmTakeover(int cameraId, string requestingKeyboardId, bool confirm)
|
||||
{
|
||||
_logger.LogInformation("Takeover confirm for camera {CameraId} by {Keyboard}: {Confirm}", cameraId, requestingKeyboardId, confirm);
|
||||
|
||||
// Remove the request
|
||||
await _requestsLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_requests.TryGetValue(cameraId, out var list))
|
||||
{
|
||||
list.RemoveAll(r => r.KeyboardId == requestingKeyboardId);
|
||||
if (list.Count == 0) _requests.Remove(cameraId);
|
||||
}
|
||||
}
|
||||
finally { _requestsLock.Release(); }
|
||||
|
||||
if (confirm)
|
||||
{
|
||||
// Unlock the current owner
|
||||
await _locksLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_locks.TryGetValue(cameraId, out var existing))
|
||||
{
|
||||
_locks.Remove(cameraId);
|
||||
_warnedLocks.Remove(cameraId);
|
||||
}
|
||||
}
|
||||
finally { _locksLock.Release(); }
|
||||
|
||||
await _broadcaster.Broadcast("lock_released", new { cameraId });
|
||||
}
|
||||
|
||||
// Notify the requester of the result
|
||||
await _broadcaster.SendTo(requestingKeyboardId, "lock_notification", new CameraLockNotification(
|
||||
confirm ? CameraLockNotificationType.Confirmed : CameraLockNotificationType.Rejected,
|
||||
cameraId, requestingKeyboardId));
|
||||
}
|
||||
|
||||
public async Task ResetExpiration(int cameraId, string keyboardId)
|
||||
{
|
||||
await _locksLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_locks.TryGetValue(cameraId, out var existing) &&
|
||||
existing.OwnerName.Equals(keyboardId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var newExpiry = DateTime.UtcNow + _expirationTimeout;
|
||||
_locks[cameraId] = existing with { ExpiresAt = newExpiry };
|
||||
_warnedLocks.Remove(cameraId);
|
||||
}
|
||||
}
|
||||
finally { _locksLock.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called every second by the expiration timer. Checks for expired and soon-to-expire locks.
|
||||
/// Ported from legacy CameraLockExpirationWorker.cs.
|
||||
/// </summary>
|
||||
public async Task CheckExpirations()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var toUnlock = new List<(int CameraId, string OwnerName)>();
|
||||
var toWarn = new List<(int CameraId, string OwnerName)>();
|
||||
|
||||
await _locksLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
foreach (var (id, lck) in _locks)
|
||||
{
|
||||
if (lck.ExpiresAt <= now)
|
||||
{
|
||||
toUnlock.Add((id, lck.OwnerName));
|
||||
}
|
||||
else if (lck.ExpiresAt <= now + _warningBefore)
|
||||
{
|
||||
if (!_warnedLocks.TryGetValue(id, out var prevExpiry) || prevExpiry != lck.ExpiresAt)
|
||||
{
|
||||
toWarn.Add((id, lck.OwnerName));
|
||||
_warnedLocks[id] = lck.ExpiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { _locksLock.Release(); }
|
||||
|
||||
// Process expirations outside the lock
|
||||
foreach (var (cameraId, owner) in toUnlock)
|
||||
{
|
||||
_logger.LogInformation("Lock expired for camera {CameraId}", cameraId);
|
||||
await Unlock(cameraId, owner);
|
||||
}
|
||||
|
||||
foreach (var (cameraId, owner) in toWarn)
|
||||
{
|
||||
await _broadcaster.SendTo(owner, "lock_notification", new CameraLockNotification(
|
||||
CameraLockNotificationType.ExpireSoon, cameraId, owner));
|
||||
_logger.LogInformation("Lock expiring soon for camera {CameraId}, notified {Owner}", cameraId, owner);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifyPendingTakeover(int cameraId)
|
||||
{
|
||||
await _requestsLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_requests.TryGetValue(cameraId, out var list) && list.Count > 0)
|
||||
{
|
||||
var first = list[0];
|
||||
// Will be handled by existing owner through ConfirmTakeOver flow
|
||||
}
|
||||
}
|
||||
finally { _requestsLock.Release(); }
|
||||
}
|
||||
|
||||
private static bool CanTakeOver(CameraLockPriority requester, CameraLockPriority holder)
|
||||
{
|
||||
// High priority can take over Low priority (same as legacy CameraLockPriorityExtensions.CanTakeOver)
|
||||
return requester == CameraLockPriority.High && holder == CameraLockPriority.Low;
|
||||
}
|
||||
}
|
||||
193
copilot-coordinator/Services/SequenceRunner.cs
Normal file
193
copilot-coordinator/Services/SequenceRunner.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using CopilotCoordinator.Models;
|
||||
|
||||
namespace CopilotCoordinator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Sequence execution engine. Ported from legacy SequenceService.cs.
|
||||
/// Runs camera rotation sequences by sending CrossSwitch commands to bridges.
|
||||
/// </summary>
|
||||
public class SequenceRunner
|
||||
{
|
||||
private readonly Dictionary<int, (CancellationTokenSource Cts, int SequenceId, DateTime StartedAt)> _running = new();
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private readonly WsBroadcaster _broadcaster;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<SequenceRunner> _logger;
|
||||
|
||||
// Sequence definitions loaded from config (in production, loaded from JSON file)
|
||||
private List<SequenceDefinition> _sequences = new();
|
||||
private List<SequenceCategory> _categories = new();
|
||||
|
||||
public SequenceRunner(WsBroadcaster broadcaster, IHttpClientFactory httpClientFactory,
|
||||
IConfiguration config, ILogger<SequenceRunner> logger)
|
||||
{
|
||||
_broadcaster = broadcaster;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void LoadSequences(List<SequenceDefinition> sequences, List<SequenceCategory> categories)
|
||||
{
|
||||
_sequences = sequences;
|
||||
_categories = categories;
|
||||
_logger.LogInformation("Loaded {Count} sequences in {CatCount} categories", sequences.Count, categories.Count);
|
||||
}
|
||||
|
||||
public IReadOnlyList<SequenceDefinition> GetSequences(int? categoryId = null)
|
||||
{
|
||||
return categoryId.HasValue
|
||||
? _sequences.Where(s => s.CategoryId == categoryId.Value).ToList()
|
||||
: _sequences;
|
||||
}
|
||||
|
||||
public IReadOnlyList<SequenceCategory> GetCategories() => _categories;
|
||||
|
||||
public async Task<RunningSequence?> Start(int viewerId, int sequenceId)
|
||||
{
|
||||
var sequence = _sequences.FirstOrDefault(s => s.Id == sequenceId);
|
||||
if (sequence == null)
|
||||
{
|
||||
_logger.LogError("Sequence {SequenceId} not found", sequenceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Stop existing sequence on this viewer
|
||||
if (_running.TryGetValue(viewerId, out var existing))
|
||||
{
|
||||
existing.Cts.Cancel();
|
||||
_running.Remove(viewerId);
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var startedAt = DateTime.UtcNow;
|
||||
_running[viewerId] = (cts, sequenceId, startedAt);
|
||||
|
||||
// Start the sequence task
|
||||
_ = Task.Run(() => RunSequenceLoop(viewerId, sequence, cts.Token), cts.Token);
|
||||
|
||||
_logger.LogInformation("Started sequence {SequenceId} on viewer {ViewerId}", sequenceId, viewerId);
|
||||
|
||||
var state = new RunningSequence(viewerId, sequenceId, startedAt, 0);
|
||||
await _broadcaster.Broadcast("sequence_started", state);
|
||||
return state;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
public async Task Stop(int viewerId)
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_running.TryGetValue(viewerId, out var existing))
|
||||
{
|
||||
existing.Cts.Cancel();
|
||||
_running.Remove(viewerId);
|
||||
_logger.LogInformation("Stopped sequence on viewer {ViewerId}", viewerId);
|
||||
}
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
|
||||
await _broadcaster.Broadcast("sequence_stopped", new { viewerId });
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RunningSequence>> GetRunning()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
return _running.Select(kvp => new RunningSequence(
|
||||
kvp.Key, kvp.Value.SequenceId, kvp.Value.StartedAt, 0
|
||||
)).ToList();
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core sequence loop. Ported from legacy SequencePeriodTask.
|
||||
/// Cycles through cameras, sending CrossSwitch to the appropriate bridge.
|
||||
/// </summary>
|
||||
private async Task RunSequenceLoop(int viewerId, SequenceDefinition sequence, CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug("Sequence loop started: viewer {ViewerId}, sequence {SequenceId}", viewerId, sequence.Id);
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
for (int i = 0; i < sequence.Cameras.Count && !ct.IsCancellationRequested; i++)
|
||||
{
|
||||
var camera = sequence.Cameras[i];
|
||||
|
||||
// Send CrossSwitch via bridge
|
||||
await SendCrossSwitch(viewerId, camera);
|
||||
_logger.LogDebug("Sequence {SeqId} viewer {ViewerId}: switched to camera {Camera}",
|
||||
sequence.Id, viewerId, camera);
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(sequence.IntervalSeconds), ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Sequence loop cancelled: viewer {ViewerId}", viewerId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Sequence loop error: viewer {ViewerId}", viewerId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send CrossSwitch command to the appropriate bridge.
|
||||
/// Determines bridge URL from camera ID ranges in configuration.
|
||||
/// </summary>
|
||||
private async Task SendCrossSwitch(int viewerId, int camera)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Determine which bridge handles this camera based on configured ranges
|
||||
var bridgeUrl = ResolveBridgeUrl(camera);
|
||||
if (bridgeUrl == null)
|
||||
{
|
||||
_logger.LogWarning("No bridge found for camera {Camera}", camera);
|
||||
return;
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
var response = await client.PostAsJsonAsync($"{bridgeUrl}/viewer/connect-live",
|
||||
new { Viewer = viewerId, Channel = camera });
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("CrossSwitch failed: viewer {Viewer} camera {Camera} status {Status}",
|
||||
viewerId, camera, response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CrossSwitch error: viewer {ViewerId} camera {Camera}", viewerId, camera);
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolveBridgeUrl(int camera)
|
||||
{
|
||||
// Read bridge mappings from config: "Bridges" section with camera ranges
|
||||
var bridges = _config.GetSection("Bridges").GetChildren();
|
||||
foreach (var bridge in bridges)
|
||||
{
|
||||
var start = bridge.GetValue<int>("CameraRangeStart");
|
||||
var end = bridge.GetValue<int>("CameraRangeEnd");
|
||||
if (camera >= start && camera <= end)
|
||||
return bridge.GetValue<string>("Url");
|
||||
}
|
||||
// Fallback: first bridge
|
||||
return bridges.FirstOrDefault()?.GetValue<string>("Url");
|
||||
}
|
||||
}
|
||||
122
copilot-coordinator/Services/WsBroadcaster.cs
Normal file
122
copilot-coordinator/Services/WsBroadcaster.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CopilotCoordinator.Models;
|
||||
|
||||
namespace CopilotCoordinator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket broadcaster. Manages connected keyboard clients and sends events.
|
||||
/// </summary>
|
||||
public class WsBroadcaster
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, (WebSocket Socket, string KeyboardId)> _clients = new();
|
||||
private readonly ILogger<WsBroadcaster> _logger;
|
||||
|
||||
public WsBroadcaster(ILogger<WsBroadcaster> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<KeyboardInfo> GetConnectedKeyboards()
|
||||
{
|
||||
return _clients.Values
|
||||
.Select(c => new KeyboardInfo(c.KeyboardId, null, DateTime.UtcNow))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task HandleConnection(WebSocket ws, string clientId, string keyboardId)
|
||||
{
|
||||
_clients[clientId] = (ws, keyboardId);
|
||||
_logger.LogInformation("Keyboard {KeyboardId} connected (client {ClientId})", keyboardId, clientId);
|
||||
|
||||
await Broadcast("keyboard_online", new { keyboardId });
|
||||
|
||||
try
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
while (ws.State == WebSocketState.Open)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(buffer, CancellationToken.None);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
break;
|
||||
|
||||
// Parse incoming messages (keyboard can send commands via WebSocket too)
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
_logger.LogDebug("Received from {KeyboardId}: {Message}", keyboardId, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
_logger.LogWarning("WebSocket error for {KeyboardId}: {Message}", keyboardId, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_clients.TryRemove(clientId, out _);
|
||||
_logger.LogInformation("Keyboard {KeyboardId} disconnected", keyboardId);
|
||||
await Broadcast("keyboard_offline", new { keyboardId });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast a message to all connected keyboards.
|
||||
/// </summary>
|
||||
public async Task Broadcast(string type, object? data = null)
|
||||
{
|
||||
var json = WsMessage.Serialize(type, data);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var deadClients = new List<string>();
|
||||
|
||||
foreach (var (clientId, (socket, _)) in _clients)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (socket.State == WebSocketState.Open)
|
||||
{
|
||||
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
deadClients.Add(clientId);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
deadClients.Add(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var id in deadClients)
|
||||
_clients.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a message to a specific keyboard.
|
||||
/// </summary>
|
||||
public async Task SendTo(string keyboardId, string type, object? data = null)
|
||||
{
|
||||
var json = WsMessage.Serialize(type, data);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
foreach (var (clientId, (socket, kbId)) in _clients)
|
||||
{
|
||||
if (kbId.Equals(keyboardId, StringComparison.OrdinalIgnoreCase) &&
|
||||
socket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Failed to send to {KeyboardId}: {Error}", keyboardId, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user