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: "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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue