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

View file

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

View file

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

View file

@ -16,6 +16,7 @@ const DOT: char = char::from_u32(0b1010_0101).unwrap();
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ViewState {
Loading,
ConnectPrompt,
Question,
Results,
}
@ -44,6 +45,7 @@ pub struct QuestionDataNet<'a> {
#[derive(Deserialize)]
pub enum ProxyOutput<'a> {
ConnectPrompt(&'a str),
Question(QuestionDataNet<'a>),
Results,
Error(&'a str),
@ -81,6 +83,7 @@ impl WheelData {
pub struct DeviceState {
view: ViewState,
device_id: Option<OwnedStr<64>>,
question: Option<QuestionData>,
wheel: WheelData,
last_index: usize,
@ -91,6 +94,7 @@ impl DeviceState {
pub const fn new() -> Self {
Self {
view: ViewState::Loading,
device_id: None,
question: None,
wheel: WheelData::empty(),
last_index: 0,
@ -119,6 +123,17 @@ impl DeviceState {
pub fn apply_proxy_output(&mut self, data: ProxyOutput<'_>) {
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) => {
let data: QuestionData = data.into();
let mut future_wheel = WheelData::empty();
@ -157,6 +172,20 @@ impl DeviceState {
}
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 {
return None;
}
@ -233,11 +262,7 @@ pub fn wheel_delta(old_angle: i32, current_angle: i32, inverted: bool) -> i32 {
};
}
if inverted {
-diff
} else {
diff
}
if inverted { -diff } else { diff }
}
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 len = text.len();
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)]
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]
fn wraps_forward_across_zero() {