request device sync when not connected
This commit is contained in:
parent
852fa32c93
commit
0daba1bd73
4 changed files with 194 additions and 130 deletions
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue