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,18 +4,26 @@ 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 =
| { | {
@ -69,9 +77,11 @@ function registerSocket(socket: Socket, deviceId: string) {
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 { }
function toDeviceQuestionData(quizData: QuizState): DeviceQuestionData | null {
if (!quizData.currentQuestion) return null; if (!quizData.currentQuestion) return null;
const question = quizData.currentQuestion; const question = quizData.currentQuestion;
const q_type = const q_type =
@ -108,6 +118,7 @@ const listener = Bun.listen({
if ("DeviceId" in data) { if ("DeviceId" in data) {
registerSocket(socket, data.DeviceId); registerSocket(socket, data.DeviceId);
writeProxyOutput(socket, { ConnectPrompt: data.DeviceId });
return; return;
} }
if ("QuizResponse" in data) { if ("QuizResponse" in data) {
@ -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

@ -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() {