From cf66d9af6d357b801fdd7fbdd4164c33acab820f Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Tue, 12 May 2026 22:54:23 +0200 Subject: [PATCH] waiting for party state --- api/src/routes/device-socket.ts | 51 ++++++++++++++++++++++++++++++--- dev-proxy/index.ts | 51 +++++++++++++++++++++++++++------ device-state/src/lib.rs | 26 +++++++++++++++++ 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/api/src/routes/device-socket.ts b/api/src/routes/device-socket.ts index b98be62..102dde7 100644 --- a/api/src/routes/device-socket.ts +++ b/api/src/routes/device-socket.ts @@ -10,8 +10,14 @@ import { pubsub, topic } from "./party-socket"; type DeviceSocketMessage = | { type: "device_message"; deviceId: string; payload: unknown } + | { type: "device_connected"; deviceId: string } | { type: "hello" } - | { type: "device_event"; deviceId: string; event: PartySocketEvent }; + | { type: "device_event"; deviceId: string; event: DeviceProxyEvent }; + +type DeviceProxyEvent = + | PartySocketEvent + | { type: "device_connect_required" } + | { type: "device_connected" }; type DeviceQuizResponsePayload = { QuizResponse: number; @@ -30,6 +36,17 @@ function isDeviceMessage( ); } +function isDeviceConnectedMessage( + value: unknown, +): value is Extract { + return ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "device_connected" && + typeof (value as { deviceId?: unknown }).deviceId === "string" + ); +} + function isDeviceQuizResponsePayload( value: unknown, ): value is DeviceQuizResponsePayload { @@ -41,7 +58,7 @@ function isDeviceQuizResponsePayload( ); } -function sendDeviceEvent(deviceId: string, event: PartySocketEvent) { +function sendDeviceEvent(deviceId: string, event: DeviceProxyEvent) { if (!devProxySocket || devProxySocket.readyState !== WebSocket.OPEN) return; devProxySocket.send( @@ -53,6 +70,25 @@ function sendDeviceEvent(deviceId: string, event: PartySocketEvent) { ); } +async function syncDeviceConnectionStatus(deviceId: string) { + const device = await db + .select() + .from(deviceConnection) + .where(eq(deviceConnection.id, deviceId)) + .then((rows) => rows[0]); + + if (!device) { + sendDeviceEvent(deviceId, { type: "device_connect_required" }); + return; + } + + await db + .update(deviceConnection) + .set({ lastSeen: new Date() }) + .where(eq(deviceConnection.id, deviceId)); + sendDeviceEvent(deviceId, { type: "device_connected" }); +} + export async function claimDeviceForUser(deviceId: string, userId: string) { await db .insert(deviceConnection) @@ -181,8 +217,14 @@ export const deviceSocketApp = new Elysia().group("/dev-socket", (app) => return; } - if (!isDeviceMessage(parsed)) return; - await forwardDevicePayload(parsed.deviceId, parsed.payload); + if (isDeviceConnectedMessage(parsed)) { + await syncDeviceConnectionStatus(parsed.deviceId); + return; + } + + if (isDeviceMessage(parsed)) { + await forwardDevicePayload(parsed.deviceId, parsed.payload); + } }, close() { if (devProxySocket === null) return; @@ -198,6 +240,7 @@ export const deviceClaimApp = new Elysia() "/:deviceId/connect", async ({ user, params }) => { await claimDeviceForUser(params.deviceId, user.id); + sendDeviceEvent(params.deviceId, { type: "device_connected" }); return { ok: true, deviceId: params.deviceId, userId: user.id }; }, { auth: true }, diff --git a/dev-proxy/index.ts b/dev-proxy/index.ts index f625f94..9973615 100644 --- a/dev-proxy/index.ts +++ b/dev-proxy/index.ts @@ -21,10 +21,15 @@ type DeviceQuestionData = { type ProxyOutput = | { ConnectPrompt: string } + | "WaitingForParty" | { Question: DeviceQuestionData } | "Results" | { Error: string }; +type ApiMessage = + | { type: "device_connected"; deviceId: string } + | { type: "device_message"; deviceId: string; payload: unknown }; + type QuizQuestion = | { type: "choice"; @@ -59,7 +64,15 @@ type ErrorEvent = { message: string; }; -type PartySocketEvent = PartyStatusEvent | QuizStateEvent | ErrorEvent; +type DeviceLifecycleEvent = + | { type: "device_connect_required" } + | { type: "device_connected" }; + +type PartySocketEvent = + | PartyStatusEvent + | QuizStateEvent + | ErrorEvent + | DeviceLifecycleEvent; const sockets = new Map(); const socketIds = new WeakMap(); @@ -81,6 +94,12 @@ function writeProxyOutput(socket: Socket, output: ProxyOutput) { socket.write(`${JSON.stringify(output)}\n`); } +function sendApiMessage(message: ApiMessage) { + if (apiSocket.readyState !== WebSocket.OPEN) return false; + apiSocket.send(JSON.stringify(message)); + return true; +} + function toDeviceQuestionData(quizData: QuizState): DeviceQuestionData | null { if (!quizData.currentQuestion) return null; const question = quizData.currentQuestion; @@ -118,19 +137,23 @@ const listener = Bun.listen({ if ("DeviceId" in data) { registerSocket(socket, data.DeviceId); - writeProxyOutput(socket, { ConnectPrompt: data.DeviceId }); + if ( + !sendApiMessage({ type: "device_connected", deviceId: data.DeviceId }) + ) { + writeProxyOutput(socket, { + Error: "API device socket not connected.", + }); + } return; } if ("QuizResponse" in data) { const deviceId = socketDeviceId(socket); if (!deviceId) return; - apiSocket?.send( - JSON.stringify({ - type: "device_message", - deviceId, - payload: { QuizResponse: data.QuizResponse }, - }), - ); + sendApiMessage({ + type: "device_message", + deviceId, + payload: { QuizResponse: data.QuizResponse }, + }); return; } }, @@ -156,6 +179,16 @@ apiSocket.onmessage = (e) => { const socket = sockets.get(message.deviceId); if (!socket) return; const event = message.event as PartySocketEvent; + if (event.type === "device_connect_required") { + writeProxyOutput(socket, { ConnectPrompt: message.deviceId }); + return; + } + + if (event.type === "device_connected") { + writeProxyOutput(socket, "WaitingForParty"); + return; + } + if (event.type === "error") { writeProxyOutput(socket, { Error: event.message }); return; diff --git a/device-state/src/lib.rs b/device-state/src/lib.rs index ca30d0a..0789c6f 100644 --- a/device-state/src/lib.rs +++ b/device-state/src/lib.rs @@ -17,6 +17,7 @@ const DOT: char = char::from_u32(0b1010_0101).unwrap(); pub enum ViewState { Loading, ConnectPrompt, + WaitingForParty, Question, Results, } @@ -46,6 +47,7 @@ pub struct QuestionDataNet<'a> { #[derive(Deserialize)] pub enum ProxyOutput<'a> { ConnectPrompt(&'a str), + WaitingForParty, Question(QuestionDataNet<'a>), Results, Error(&'a str), @@ -134,6 +136,10 @@ impl DeviceState { self.question = None; self.view = ViewState::ConnectPrompt; } + ProxyOutput::WaitingForParty => { + self.question = None; + self.view = ViewState::WaitingForParty; + } ProxyOutput::Question(data) => { let data: QuestionData = data.into(); let mut future_wheel = WheelData::empty(); @@ -186,6 +192,13 @@ impl DeviceState { )); } + if self.view == ViewState::WaitingForParty { + return Some(( + OwnedStr::from_str("Connected").unwrap(), + OwnedStr::from_str("Waiting party").unwrap(), + )); + } + if self.view != ViewState::Question { return None; } @@ -370,6 +383,19 @@ mod tests { assert_eq!(state.view_state(), ViewState::Results); } + #[test] + fn parses_and_renders_waiting_for_party() { + let data = parse_proxy_output(r#""WaitingForParty""#).unwrap(); + let mut state = DeviceState::new(); + + state.apply_proxy_output(data); + + assert_eq!(state.view_state(), ViewState::WaitingForParty); + let (line1, line2) = state.render_lines().unwrap(); + assert_eq!(line1.as_str(), "Connected"); + assert_eq!(line2.as_str(), "Waiting party"); + } + #[test] fn wraps_forward_across_zero() { assert_eq!(wheel_delta(4090, 5, false), 11);