using System.Net.WebSockets; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using backend.Data; using backend.Devices; using Microsoft.EntityFrameworkCore; namespace backend.DeviceHub; public static class DeviceHubEndpoint { public static IEndpointRouteBuilder MapDeviceHub(this IEndpointRouteBuilder app) { app.Map("/device", async (HttpContext ctx, DeviceAuth auth, DeviceRegistry reg, AppDbContext db) => { if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; } var device = await auth.AuthenticateAsync( ctx.Request.Headers.Authorization.ToString(), ctx.RequestAborted); if (device is null) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } using var ws = await ctx.WebSockets.AcceptWebSocketAsync(); await using var active = new ActiveDevice(device.Id, ws); if (!reg.TryRegister(active)) { await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation, "device already connected", ctx.RequestAborted); return; } try { await HandleAsync(active, device, db, ctx.RequestAborted); } finally { reg.Unregister(device.Id); } }); return app; } private static async Task HandleAsync(ActiveDevice active, Device device, AppDbContext db, CancellationToken ct) { var buffer = new byte[8192]; var ws = active.Ws; while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested) { using var msg = new MemoryStream(); WebSocketReceiveResult res; while (true) { res = await ws.ReceiveAsync(buffer, ct); if (res.MessageType == WebSocketMessageType.Close) return; msg.Write(buffer, 0, res.Count); if (res.EndOfMessage) break; } if (res.MessageType == WebSocketMessageType.Binary) { if (msg.Length != 3840) continue; // Binary routing into a session lives in Task 18. continue; } var text = Encoding.UTF8.GetString(msg.ToArray()); using var doc = JsonDocument.Parse(text); var type = doc.RootElement.GetProperty("type").GetString(); switch (type) { case "hello": await SendHelloAckAsync(active, device, ct); break; case "ping": device.LastSeenAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); await active.SendEnvelopeAsync(new PongEnvelope("pong"), ct); break; } } } private static async Task SendHelloAckAsync(ActiveDevice active, Device device, CancellationToken ct) { var enabled = device.Config?.EnabledToolsJson ?? "[]"; var config = JsonNode.Parse($$""" { "voice": "{{Escape(device.Config?.Voice)}}", "model": "{{Escape(device.Config?.Model)}}", "system_prompt": "{{Escape(device.Config?.SystemPrompt)}}", "idle_timeout_seconds": {{device.Config?.IdleTimeoutSeconds ?? 30}}, "enabled_tools": {{enabled}} } """)!.AsObject(); await active.WriteEnvelopeAsync(new JsonObject { ["type"] = "hello_ack", ["config"] = config, }, ct); } private static string Escape(string? s) => (s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\""); }