using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using CopilotCoordinator.Models;
namespace CopilotCoordinator.Services;
///
/// WebSocket broadcaster. Manages connected keyboard clients and sends events.
///
public class WsBroadcaster
{
private readonly ConcurrentDictionary _clients = new();
private readonly ILogger _logger;
public WsBroadcaster(ILogger logger)
{
_logger = logger;
}
public IReadOnlyList 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 });
}
}
///
/// Broadcast a message to all connected keyboards.
///
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();
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 _);
}
///
/// Send a message to a specific keyboard.
///
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);
}
}
}
}
}