request device sync when not connected

This commit is contained in:
Daniel Bulant 2026-05-12 22:47:24 +02:00
parent 852fa32c93
commit 0daba1bd73
No known key found for this signature in database
4 changed files with 194 additions and 130 deletions

View file

@ -4,51 +4,59 @@ type ApiEnvelope =
| { type: "hello" } | { type: "hello" }
| { type: "device_event"; deviceId: string; event: unknown }; | { type: "device_event"; deviceId: string; event: unknown };
type DeviceMessage = { type DeviceMessage =
DeviceId: string; | {
} | { DeviceId: string;
QuizResponse: number; }
} | {
QuizResponse: number;
};
type DeviceQuestionData = { type DeviceQuestionData = {
text: string; text: string;
points: number; points: number;
index: number; index: number;
q_type: "Choice" | { Numeric: { min: number; max: number } } q_type: "Choice" | { Numeric: { min: number; max: number } };
} };
type ProxyOutput =
| { ConnectPrompt: string }
| { Question: DeviceQuestionData }
| "Results"
| { Error: string };
type QuizQuestion = type QuizQuestion =
| { | {
type: "choice"; type: "choice";
text: string; text: string;
points: number; points: number;
} }
| { | {
type: "numeric"; type: "numeric";
text: string; text: string;
points: number; points: number;
range: { min: number; max: number }; range: { min: number; max: number };
}; };
type QuizState = { type QuizState = {
status: "running" | "results"; status: "running" | "results";
questionIndex: number; questionIndex: number;
currentQuestion: QuizQuestion | null; currentQuestion: QuizQuestion | null;
}; };
type PartyStatusEvent = { type PartyStatusEvent = {
type: "party_status"; type: "party_status";
party: { data?: QuizState } | null; party: { data?: QuizState } | null;
}; };
type QuizStateEvent = { type QuizStateEvent = {
type: "quiz_state"; type: "quiz_state";
quiz: QuizState; quiz: QuizState;
}; };
type ErrorEvent = { type ErrorEvent = {
type: "error"; type: "error";
message: string; message: string;
}; };
type PartySocketEvent = PartyStatusEvent | QuizStateEvent | ErrorEvent; type PartySocketEvent = PartyStatusEvent | QuizStateEvent | ErrorEvent;
@ -65,66 +73,69 @@ function registerSocket(socket: Socket, deviceId: string) {
const existing = sockets.get(deviceId); const existing = sockets.get(deviceId);
if (existing && existing !== socket) existing.end(); if (existing && existing !== socket) existing.end();
sockets.set(deviceId, socket); sockets.set(deviceId, socket);
socketIds.set(socket, deviceId); socketIds.set(socket, deviceId);
console.log("Registered", socket.remoteAddress, deviceId); console.log("Registered", socket.remoteAddress, deviceId);
} }
function toDeviceQuestionData( function writeProxyOutput(socket: Socket, output: ProxyOutput) {
quizData: QuizState, socket.write(`${JSON.stringify(output)}\n`);
): DeviceQuestionData | null { }
if (!quizData.currentQuestion) return null;
const question = quizData.currentQuestion;
const q_type =
question.type === "choice"
? "Choice"
: { Numeric: { min: question.range.min, max: question.range.max } };
return { function toDeviceQuestionData(quizData: QuizState): DeviceQuestionData | null {
text: question.text, if (!quizData.currentQuestion) return null;
points: question.points, const question = quizData.currentQuestion;
index: quizData.questionIndex, const q_type =
q_type, question.type === "choice"
}; ? "Choice"
: { Numeric: { min: question.range.min, max: question.range.max } };
return {
text: question.text,
points: question.points,
index: quizData.questionIndex,
q_type,
};
} }
const listener = Bun.listen({ const listener = Bun.listen({
port: 7070, port: 7070,
hostname: "0.0.0.0", hostname: "0.0.0.0",
socket: { socket: {
open(socket) { open(socket) {
socket.setKeepAlive(true); socket.setKeepAlive(true);
console.log("Connection", socket.remoteAddress, socket.remotePort); console.log("Connection", socket.remoteAddress, socket.remotePort);
}, },
data(socket, buf) { data(socket, buf) {
const raw = new TextDecoder().decode(buf).trim(); const raw = new TextDecoder().decode(buf).trim();
let data: DeviceMessage; let data: DeviceMessage;
try { try {
data = JSON.parse(raw); data = JSON.parse(raw);
} catch { } catch {
return; return;
} }
console.log("Data", socket.remoteAddress, data); console.log("Data", socket.remoteAddress, data);
if (!data) return; if (!data) return;
if ("DeviceId" in data) { if ("DeviceId" in data) {
registerSocket(socket, data.DeviceId); registerSocket(socket, data.DeviceId);
return; writeProxyOutput(socket, { ConnectPrompt: data.DeviceId });
} return;
if ("QuizResponse" in data) { }
const deviceId = socketDeviceId(socket); if ("QuizResponse" in data) {
if (!deviceId) return; const deviceId = socketDeviceId(socket);
apiSocket?.send( if (!deviceId) return;
JSON.stringify({ apiSocket?.send(
type: "device_message", JSON.stringify({
deviceId, type: "device_message",
payload: { QuizResponse: data.QuizResponse }, deviceId,
}), payload: { QuizResponse: data.QuizResponse },
); }),
return; );
} return;
}
}, },
close(socket) { close(socket) {
console.log("Connection", socket.remoteAddress); console.log("Connection", socket.remoteAddress);
const deviceId = socketDeviceId(socket); const deviceId = socketDeviceId(socket);
if (deviceId && sockets.get(deviceId) === socket) { if (deviceId && sockets.get(deviceId) === socket) {
sockets.delete(deviceId); sockets.delete(deviceId);
@ -146,7 +157,7 @@ apiSocket.onmessage = (e) => {
if (!socket) return; if (!socket) return;
const event = message.event as PartySocketEvent; const event = message.event as PartySocketEvent;
if (event.type === "error") { if (event.type === "error") {
socket.write(`${JSON.stringify({ Error: event.message })}\n`); writeProxyOutput(socket, { Error: event.message });
return; return;
} }
@ -154,25 +165,27 @@ apiSocket.onmessage = (e) => {
const quizData = event.party?.data ?? null; const quizData = event.party?.data ?? null;
if (!quizData) return; if (!quizData) return;
const question = toDeviceQuestionData(quizData); const question = toDeviceQuestionData(quizData);
socket.write( if (question) {
`${JSON.stringify({ Question: question, Status: quizData.status })}\n`, writeProxyOutput(socket, { Question: question });
); } else if (quizData.status === "results") {
writeProxyOutput(socket, "Results");
}
return; return;
} }
if (event.type === "quiz_state") { if (event.type === "quiz_state") {
const question = toDeviceQuestionData(event.quiz); const question = toDeviceQuestionData(event.quiz);
socket.write( if (question) {
`${JSON.stringify({ Question: question, Status: event.quiz.status })}\n`, writeProxyOutput(socket, { Question: question });
); } else if (event.quiz.status === "results") {
writeProxyOutput(socket, "Results");
}
return; return;
} }
socket.write(`${JSON.stringify(message.event)}\n`); writeProxyOutput(socket, { Error: "Unsupported proxy event." });
}; };
apiSocket.onerror = (error) => { apiSocket.onerror = (error) => {
console.error(error); console.error(error);
}; };

View file

@ -1,12 +1,12 @@
{ {
"name": "dev-proxy", "name": "dev-proxy",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"
} }
} }

View file

@ -1,29 +1,29 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": ["ESNext"], "lib": ["ESNext"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
"moduleDetection": "force", "moduleDetection": "force",
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, "allowJs": true,
// Bundler mode // Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
// Best practices // Best practices
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false
} }
} }

View file

@ -16,6 +16,7 @@ const DOT: char = char::from_u32(0b1010_0101).unwrap();
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ViewState { pub enum ViewState {
Loading, Loading,
ConnectPrompt,
Question, Question,
Results, Results,
} }
@ -44,6 +45,7 @@ pub struct QuestionDataNet<'a> {
#[derive(Deserialize)] #[derive(Deserialize)]
pub enum ProxyOutput<'a> { pub enum ProxyOutput<'a> {
ConnectPrompt(&'a str),
Question(QuestionDataNet<'a>), Question(QuestionDataNet<'a>),
Results, Results,
Error(&'a str), Error(&'a str),
@ -81,6 +83,7 @@ impl WheelData {
pub struct DeviceState { pub struct DeviceState {
view: ViewState, view: ViewState,
device_id: Option<OwnedStr<64>>,
question: Option<QuestionData>, question: Option<QuestionData>,
wheel: WheelData, wheel: WheelData,
last_index: usize, last_index: usize,
@ -91,6 +94,7 @@ impl DeviceState {
pub const fn new() -> Self { pub const fn new() -> Self {
Self { Self {
view: ViewState::Loading, view: ViewState::Loading,
device_id: None,
question: None, question: None,
wheel: WheelData::empty(), wheel: WheelData::empty(),
last_index: 0, last_index: 0,
@ -119,6 +123,17 @@ impl DeviceState {
pub fn apply_proxy_output(&mut self, data: ProxyOutput<'_>) { pub fn apply_proxy_output(&mut self, data: ProxyOutput<'_>) {
match data { match data {
ProxyOutput::ConnectPrompt(device_id) => {
let mut owned_device_id = OwnedStr::new();
for char in device_id.chars() {
if owned_device_id.try_push(char).is_err() {
break;
}
}
self.device_id = Some(owned_device_id);
self.question = None;
self.view = ViewState::ConnectPrompt;
}
ProxyOutput::Question(data) => { ProxyOutput::Question(data) => {
let data: QuestionData = data.into(); let data: QuestionData = data.into();
let mut future_wheel = WheelData::empty(); let mut future_wheel = WheelData::empty();
@ -157,6 +172,20 @@ impl DeviceState {
} }
pub fn render_lines(&mut self) -> Option<(OwnedStr<16>, OwnedStr<16>)> { pub fn render_lines(&mut self) -> Option<(OwnedStr<16>, OwnedStr<16>)> {
if self.view == ViewState::ConnectPrompt {
let device_id = self.device_id.as_ref()?;
let mut display_id: OwnedStr<16> = OwnedStr::new();
for char in device_id.as_str().chars() {
if display_id.try_push(char).is_err() {
break;
}
}
return Some((
OwnedStr::from_str("Connect device").unwrap(),
center_str::<16>(display_id.as_str(), 16).unwrap(),
));
}
if self.view != ViewState::Question { if self.view != ViewState::Question {
return None; return None;
} }
@ -233,11 +262,7 @@ pub fn wheel_delta(old_angle: i32, current_angle: i32, inverted: bool) -> i32 {
}; };
} }
if inverted { if inverted { -diff } else { diff }
-diff
} else {
diff
}
} }
pub fn apply_wheel_delta( pub fn apply_wheel_delta(
@ -304,7 +329,10 @@ impl<const CAP: usize> ufmt::uWrite for OwnedStrWriter<CAP> {
} }
} }
pub fn center_str<const CAP: usize>(text: &str, width: usize) -> Result<OwnedStr<CAP>, owned_str::Error> { pub fn center_str<const CAP: usize>(
text: &str,
width: usize,
) -> Result<OwnedStr<CAP>, owned_str::Error> {
let mut res = OwnedStr::new(); let mut res = OwnedStr::new();
let len = text.len(); let len = text.len();
let padding = (width.saturating_sub(len) + 1) / 2; let padding = (width.saturating_sub(len) + 1) / 2;
@ -317,7 +345,30 @@ pub fn center_str<const CAP: usize>(text: &str, width: usize) -> Result<OwnedStr
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{apply_wheel_delta, wheel_delta}; use super::{DeviceState, ViewState, apply_wheel_delta, parse_proxy_output, wheel_delta};
#[test]
fn parses_and_renders_connect_prompt() {
let data = parse_proxy_output(r#"{"ConnectPrompt":"esp32-1"}"#).unwrap();
let mut state = DeviceState::new();
state.apply_proxy_output(data);
assert_eq!(state.view_state(), ViewState::ConnectPrompt);
let (line1, line2) = state.render_lines().unwrap();
assert_eq!(line1.as_str(), "Connect device");
assert_eq!(line2.as_str(), " esp32-1");
}
#[test]
fn parses_results_message() {
let data = parse_proxy_output(r#""Results""#).unwrap();
let mut state = DeviceState::new();
state.apply_proxy_output(data);
assert_eq!(state.view_state(), ViewState::Results);
}
#[test] #[test]
fn wraps_forward_across_zero() { fn wraps_forward_across_zero() {