Compare commits

..

No commits in common. "a7a7eeac0c371e83abd6f000fce911a5c5f623b5" and "852fa32c93199c91d27259516f2a8d6da7b8c96d" have entirely different histories.

13 changed files with 190 additions and 599 deletions

View file

@ -375,15 +375,8 @@ export const relations = defineRelations(
track, track,
trackArtist, trackArtist,
user, user,
deviceConnection,
}, },
(r) => ({ (r) => ({
deviceConnection: {
user: r.one.user({
from: r.deviceConnection.userId,
to: r.user.id,
}),
},
artist: { artist: {
artistGenres: r.many.artistGenre(), artistGenres: r.many.artistGenre(),
artistImages: r.many.artistImage(), artistImages: r.many.artistImage(),
@ -608,7 +601,6 @@ export const relations = defineRelations(
from: r.user.id, from: r.user.id,
to: r.party.hostId, to: r.party.hostId,
}), }),
deviceConnection: r.many.deviceConnection(),
}, },
}), }),
); );

View file

@ -10,35 +10,14 @@ import { pubsub, topic } from "./party-socket";
type DeviceSocketMessage = type DeviceSocketMessage =
| { type: "device_message"; deviceId: string; payload: unknown } | { type: "device_message"; deviceId: string; payload: unknown }
| { type: "device_status_request"; deviceId: string }
| { type: "hello" } | { type: "hello" }
| { type: "device_event"; deviceId: string; event: DeviceProxyEvent }; | { type: "device_event"; deviceId: string; event: PartySocketEvent };
type DeviceProxyEvent =
| PartySocketEvent
| { type: "device_connect_required" }
| { type: "device_connected" };
type DeviceQuizResponsePayload = { type DeviceQuizResponsePayload = {
QuizResponse: number; QuizResponse: number;
}; };
type DeviceConnectionRecord = typeof deviceConnection.$inferSelect; let devProxySocket: WebSocket | null = null;
type DevProxySocket = {
send: (message: string) => unknown;
};
let devProxySocket: DevProxySocket | null = null;
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string) {
return Promise.race([
promise,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
}),
]);
}
function isDeviceMessage( function isDeviceMessage(
value: unknown, value: unknown,
@ -51,17 +30,6 @@ function isDeviceMessage(
); );
} }
function isDeviceStatusRequestMessage(
value: unknown,
): value is Extract<DeviceSocketMessage, { type: "device_status_request" }> {
return (
typeof value === "object" &&
value !== null &&
(value as { type?: unknown }).type === "device_status_request" &&
typeof (value as { deviceId?: unknown }).deviceId === "string"
);
}
function isDeviceQuizResponsePayload( function isDeviceQuizResponsePayload(
value: unknown, value: unknown,
): value is DeviceQuizResponsePayload { ): value is DeviceQuizResponsePayload {
@ -73,64 +41,16 @@ function isDeviceQuizResponsePayload(
); );
} }
function sendDeviceEvent(deviceId: string, event: DeviceProxyEvent) { function sendDeviceEvent(deviceId: string, event: PartySocketEvent) {
if (!devProxySocket) { if (!devProxySocket || devProxySocket.readyState !== WebSocket.OPEN) return;
console.log("[device-socket] no dev proxy for event", deviceId, event.type);
return;
}
try { devProxySocket.send(
console.log("[device-socket] sending event", deviceId, event.type); JSON.stringify({
devProxySocket.send( type: "device_event",
JSON.stringify({
type: "device_event",
deviceId,
event,
} satisfies DeviceSocketMessage),
);
} catch (error) {
console.error("[device-socket] failed to send event", error);
devProxySocket = null;
}
}
async function syncDeviceConnectionStatus(deviceId: string) {
console.log("[device-socket] status request", deviceId);
let device: DeviceConnectionRecord | undefined;
try {
console.log("[device-socket] lookup device start", deviceId);
device = await withTimeout(
db
.select()
.from(deviceConnection)
.where(eq(deviceConnection.id, deviceId))
.then((rows) => rows[0]),
2_000,
`device lookup ${deviceId}`,
);
console.log(
"[device-socket] lookup device result",
deviceId, deviceId,
device ? "claimed" : "missing", event,
); } satisfies DeviceSocketMessage),
} catch (error) { );
console.error("[device-socket] lookup device failed", deviceId, error);
sendDeviceEvent(deviceId, { type: "device_connect_required" });
return;
}
if (!device) {
console.log("[device-socket] device unclaimed", deviceId);
sendDeviceEvent(deviceId, { type: "device_connect_required" });
return;
}
console.log("[device-socket] device claimed", deviceId, device.userId);
await db
.update(deviceConnection)
.set({ lastSeen: new Date() })
.where(eq(deviceConnection.id, deviceId));
sendDeviceEvent(deviceId, { type: "device_connected" });
} }
export async function claimDeviceForUser(deviceId: string, userId: string) { export async function claimDeviceForUser(deviceId: string, userId: string) {
@ -155,7 +75,7 @@ export async function publishDeviceEventForUser(
userId: string, userId: string,
event: PartySocketEvent, event: PartySocketEvent,
) { ) {
if (!devProxySocket) return; if (!devProxySocket || devProxySocket.readyState !== WebSocket.OPEN) return;
const devices = await db const devices = await db
.select() .select()
@ -246,27 +166,25 @@ export const deviceSocketApp = new Elysia().group("/dev-socket", (app) =>
.get("/test", () => ({ ok: 1 })) .get("/test", () => ({ ok: 1 }))
.ws("/ws", { .ws("/ws", {
open(ws) { open(ws) {
console.log("[device-socket] dev proxy connected"); devProxySocket = ws as unknown as WebSocket;
devProxySocket = ws;
ws.send( ws.send(
JSON.stringify({ type: "hello" } satisfies DeviceSocketMessage), JSON.stringify({ type: "hello" } satisfies DeviceSocketMessage),
); );
}, },
message: async (_ws, message: DeviceSocketMessage) => { message: async (_ws, message) => {
if (typeof message !== "object") return; if (typeof message !== "string") return;
console.log("[device-socket] received", message.type);
if (isDeviceStatusRequestMessage(message)) { let parsed: DeviceSocketMessage;
await syncDeviceConnectionStatus(message.deviceId); try {
parsed = JSON.parse(message) as DeviceSocketMessage;
} catch {
return; return;
} }
if (isDeviceMessage(message)) { if (!isDeviceMessage(parsed)) return;
await forwardDevicePayload(message.deviceId, message.payload); await forwardDevicePayload(parsed.deviceId, parsed.payload);
}
}, },
close() { close() {
console.log("[device-socket] dev proxy disconnected");
if (devProxySocket === null) return; if (devProxySocket === null) return;
devProxySocket = null; devProxySocket = null;
}, },
@ -280,7 +198,6 @@ export const deviceClaimApp = new Elysia()
"/:deviceId/connect", "/:deviceId/connect",
async ({ user, params }) => { async ({ user, params }) => {
await claimDeviceForUser(params.deviceId, user.id); await claimDeviceForUser(params.deviceId, user.id);
sendDeviceEvent(params.deviceId, { type: "device_connected" });
return { ok: true, deviceId: params.deviceId, userId: user.id }; return { ok: true, deviceId: params.deviceId, userId: user.id };
}, },
{ auth: true }, { auth: true },

View file

@ -4,82 +4,58 @@ 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 }
| "WaitingForParty"
| { Question: DeviceQuestionData }
| "Results"
| { Error: string };
type ApiMessage =
| { type: "device_status_request"; deviceId: string }
| { type: "device_message"; deviceId: string; payload: unknown };
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 DeviceLifecycleEvent = type PartySocketEvent = PartyStatusEvent | QuizStateEvent | ErrorEvent;
| { type: "device_connect_required" }
| { type: "device_connected" };
type PartySocketEvent =
| PartyStatusEvent
| QuizStateEvent
| ErrorEvent
| DeviceLifecycleEvent;
const sockets = new Map<string, Socket>(); const sockets = new Map<string, Socket>();
const socketIds = new WeakMap<Socket, string>(); const socketIds = new WeakMap<Socket, string>();
const API_SOCKET_URL = "ws://localhost:4000/api/dev-socket/ws"; const apiSocket = new WebSocket("ws://localhost:4000/api/dev-socket/ws");
let apiSocket: WebSocket | null = null;
let apiReconnectTimer: ReturnType<typeof setTimeout> | null = null;
const pendingDeviceStatus = new Set<string>();
function socketDeviceId(socket: Socket) { function socketDeviceId(socket: Socket) {
return socketIds.get(socket); return socketIds.get(socket);
@ -89,155 +65,75 @@ 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 writeProxyOutput(socket: Socket, output: ProxyOutput) { function toDeviceQuestionData(
socket.write(`${JSON.stringify(output)}\n`); 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 sendApiMessage(message: ApiMessage) { return {
if (!apiSocket || apiSocket.readyState !== WebSocket.OPEN) return false; text: question.text,
console.log("API send", message.type, message.deviceId); points: question.points,
apiSocket.send(JSON.stringify(message)); index: quizData.questionIndex,
return true; q_type,
} };
function requestDeviceStatus(deviceId: string) {
pendingDeviceStatus.add(deviceId);
if (sendApiMessage({ type: "device_status_request", deviceId })) {
console.log("Requested device status", deviceId);
pendingDeviceStatus.delete(deviceId);
return;
}
console.log("Queued device status request", deviceId);
}
function flushPendingDeviceStatus() {
for (const deviceId of pendingDeviceStatus) {
if (
sockets.has(deviceId) &&
sendApiMessage({ type: "device_status_request", deviceId })
) {
console.log("Flushed device status request", deviceId);
pendingDeviceStatus.delete(deviceId);
}
}
}
function disconnectDeviceClients(reason: string) {
console.log("Disconnecting device clients", reason, sockets.size);
for (const socket of sockets.values()) {
socket.end();
}
sockets.clear();
pendingDeviceStatus.clear();
}
function scheduleApiReconnect() {
if (apiReconnectTimer) return;
apiReconnectTimer = setTimeout(() => {
apiReconnectTimer = null;
connectApiSocket();
}, 500);
}
function connectApiSocket() {
if (
apiSocket?.readyState === WebSocket.OPEN ||
apiSocket?.readyState === WebSocket.CONNECTING
) {
return;
}
console.log("Connecting to API device socket");
apiSocket = new WebSocket(API_SOCKET_URL);
apiSocket.onmessage = handleApiMessage;
apiSocket.onerror = (error) => {
console.error("API device socket error", error);
};
apiSocket.onclose = () => {
console.log("API device socket closed; reconnecting");
apiSocket = null;
disconnectDeviceClients("api socket closed");
scheduleApiReconnect();
};
apiSocket.onopen = () => {
console.log("Connected to API device socket");
flushPendingDeviceStatus();
};
}
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({ 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);
console.log( return;
"Requesting device status", }
data.DeviceId, if ("QuizResponse" in data) {
"apiState", const deviceId = socketDeviceId(socket);
apiSocket?.readyState, if (!deviceId) return;
); apiSocket?.send(
requestDeviceStatus(data.DeviceId); JSON.stringify({
return; type: "device_message",
} deviceId,
if ("QuizResponse" in data) { payload: { QuizResponse: data.QuizResponse },
const deviceId = socketDeviceId(socket); }),
if (!deviceId) return; );
sendApiMessage({ return;
type: "device_message", }
deviceId,
payload: { QuizResponse: data.QuizResponse },
});
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);
pendingDeviceStatus.delete(deviceId);
} }
}, },
}, },
}); });
function handleApiMessage(e: MessageEvent) { apiSocket.onmessage = (e) => {
let message: ApiEnvelope; let message: ApiEnvelope;
try { try {
message = JSON.parse(e.data) as ApiEnvelope; message = JSON.parse(e.data) as ApiEnvelope;
@ -245,29 +141,12 @@ function handleApiMessage(e: MessageEvent) {
return; return;
} }
console.log("API recv", message.type);
if (message.type !== "device_event") return; if (message.type !== "device_event") return;
const socket = sockets.get(message.deviceId); const socket = sockets.get(message.deviceId);
if (!socket) { if (!socket) return;
console.log("No TCP socket for API event", message.deviceId);
return;
}
const event = message.event as PartySocketEvent; const event = message.event as PartySocketEvent;
console.log("API device event", message.deviceId, event.type);
if (event.type === "device_connect_required") {
console.log("Writing connect prompt", message.deviceId);
writeProxyOutput(socket, { ConnectPrompt: message.deviceId });
return;
}
if (event.type === "device_connected") {
console.log("Writing waiting-for-party", message.deviceId);
writeProxyOutput(socket, "WaitingForParty");
return;
}
if (event.type === "error") { if (event.type === "error") {
writeProxyOutput(socket, { Error: event.message }); socket.write(`${JSON.stringify({ Error: event.message })}\n`);
return; return;
} }
@ -275,27 +154,31 @@ function handleApiMessage(e: MessageEvent) {
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);
if (question) { socket.write(
writeProxyOutput(socket, { Question: question }); `${JSON.stringify({ Question: question, Status: quizData.status })}\n`,
} 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);
if (question) { socket.write(
writeProxyOutput(socket, { Question: question }); `${JSON.stringify({ Question: question, Status: event.quiz.status })}\n`,
} else if (event.quiz.status === "results") { );
writeProxyOutput(socket, "Results");
}
return; return;
} }
writeProxyOutput(socket, { Error: "Unsupported proxy event." }); socket.write(`${JSON.stringify(message.event)}\n`);
} };
connectApiSocket();
apiSocket.onerror = (error) => {
console.error(error);
};
apiSocket.onopen = () => {
console.log("Connected to API device socket");
};
console.log(`Started on :${listener.port}`); console.log(`Started on :${listener.port}`);

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,9 +16,6 @@ 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,
Reconnecting,
ConnectPrompt,
WaitingForParty,
Question, Question,
Results, Results,
} }
@ -47,8 +44,6 @@ pub struct QuestionDataNet<'a> {
#[derive(Deserialize)] #[derive(Deserialize)]
pub enum ProxyOutput<'a> { pub enum ProxyOutput<'a> {
ConnectPrompt(&'a str),
WaitingForParty,
Question(QuestionDataNet<'a>), Question(QuestionDataNet<'a>),
Results, Results,
Error(&'a str), Error(&'a str),
@ -86,7 +81,6 @@ 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,
@ -97,7 +91,6 @@ 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,
@ -106,19 +99,12 @@ impl DeviceState {
} }
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.view = ViewState::Loading;
self.question = None; self.question = None;
self.wheel = WheelData::empty(); self.wheel = WheelData::empty();
self.last_index = 0; self.last_index = 0;
self.title_offset = 0; self.title_offset = 0;
} }
pub fn reconnecting(&mut self) {
self.question = None;
self.wheel = WheelData::empty();
self.view = ViewState::Reconnecting;
}
pub fn view_state(&self) -> ViewState { pub fn view_state(&self) -> ViewState {
self.view self.view
} }
@ -133,21 +119,6 @@ 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::WaitingForParty => {
self.question = None;
self.view = ViewState::WaitingForParty;
}
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();
@ -186,41 +157,6 @@ 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::Loading {
return Some((
OwnedStr::from_str("Connecting").unwrap(),
OwnedStr::from_str("Please wait").unwrap(),
));
}
if self.view == ViewState::Reconnecting {
return Some((
OwnedStr::from_str("Reconnecting").unwrap(),
OwnedStr::from_str("Please wait").unwrap(),
));
}
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::WaitingForParty {
return Some((
OwnedStr::from_str("Connected").unwrap(),
OwnedStr::from_str("Waiting party").unwrap(),
));
}
if self.view != ViewState::Question { if self.view != ViewState::Question {
return None; return None;
} }
@ -297,7 +233,11 @@ 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( pub fn apply_wheel_delta(
@ -364,10 +304,7 @@ impl<const CAP: usize> ufmt::uWrite for OwnedStrWriter<CAP> {
} }
} }
pub fn center_str<const CAP: usize>( pub fn center_str<const CAP: usize>(text: &str, width: usize) -> Result<OwnedStr<CAP>, owned_str::Error> {
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;
@ -380,65 +317,7 @@ pub fn center_str<const CAP: usize>(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{DeviceState, ViewState, apply_wheel_delta, parse_proxy_output, wheel_delta}; use super::{apply_wheel_delta, 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 parses_and_renders_waiting_for_party() {
let data = parse_proxy_output(r#""WaitingForParty""#).unwrap();
let mut state = DeviceState::new();
state.apply_proxy_output(data);
assert_eq!(state.view_state(), ViewState::WaitingForParty);
let (line1, line2) = state.render_lines().unwrap();
assert_eq!(line1.as_str(), "Connected");
assert_eq!(line2.as_str(), "Waiting party");
}
#[test]
fn renders_reconnecting_state() {
let mut state = DeviceState::new();
state.reconnecting();
assert_eq!(state.view_state(), ViewState::Reconnecting);
let (line1, line2) = state.render_lines().unwrap();
assert_eq!(line1.as_str(), "Reconnecting");
assert_eq!(line2.as_str(), "Please wait");
}
#[test]
fn renders_loading_state() {
let mut state = DeviceState::new();
let (line1, line2) = state.render_lines().unwrap();
assert_eq!(line1.as_str(), "Connecting");
assert_eq!(line2.as_str(), "Please wait");
}
#[test] #[test]
fn wraps_forward_across_zero() { fn wraps_forward_across_zero() {

View file

@ -49,11 +49,6 @@ pub async fn reset_state() {
state.reset(); state.reset();
} }
pub async fn reconnecting_state() {
let mut state = STATE.lock().await;
state.reconnecting();
}
#[panic_handler] #[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! { fn panic(info: &core::panic::PanicInfo) -> ! {
println!("PANIC! {:?}", info); println!("PANIC! {:?}", info);

View file

@ -14,9 +14,8 @@ use esp_radio::wifi::sta::StationConfig;
use esp_radio::wifi::{Config, ControllerConfig, scan::ScanConfig}; use esp_radio::wifi::{Config, ControllerConfig, scan::ScanConfig};
use crate::screen::overwrite_lcd; use crate::screen::overwrite_lcd;
use crate::{WIFI_NETWORK, WIFI_PASSWORD};
use crate::{buffer::wait_for_config, tcp_read_loop, tcp_write_loop}; use crate::{buffer::wait_for_config, tcp_read_loop, tcp_write_loop};
use crate::{reconnecting_state, reset_state}; use crate::{WIFI_NETWORK, WIFI_PASSWORD};
pub struct NetworkConfig<'a> { pub struct NetworkConfig<'a> {
pub wifi: WIFI<'a>, pub wifi: WIFI<'a>,
@ -130,12 +129,10 @@ pub async fn network_setup_task(
.await .await
{ {
println!("tcp connect error: {:?}", e); println!("tcp connect error: {:?}", e);
reconnecting_state().await;
overwrite_lcd("TCP error", &format!("{}", e)).await; overwrite_lcd("TCP error", &format!("{}", e)).await;
Timer::after_millis(1000).await; Timer::after_millis(1000).await;
continue; continue;
} }
reset_state().await;
overwrite_lcd("Connected", "").await; overwrite_lcd("Connected", "").await;
let cancel = Signal::<CriticalSectionRawMutex, ()>::new(); let cancel = Signal::<CriticalSectionRawMutex, ()>::new();
@ -152,11 +149,10 @@ pub async fn network_setup_task(
if !stack.is_config_up() { if !stack.is_config_up() {
println!("wifi down, reconnecting wifi"); println!("wifi down, reconnecting wifi");
reconnecting_state().await;
break; break;
} }
reconnecting_state().await; overwrite_lcd("Connection close", "").await;
Timer::after_millis(500).await; Timer::after_millis(500).await;
} }
} }

View file

@ -4,11 +4,11 @@ use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
use clap::Parser; use clap::Parser;
use crossterm::cursor::Show;
use crossterm::event;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::queue;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use crossterm::cursor::Show;
use crossterm::queue;
use crossterm::event;
use device_state::{DeviceState, WriteType, apply_wheel_delta}; use device_state::{DeviceState, WriteType, apply_wheel_delta};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
@ -88,7 +88,6 @@ async fn main() -> io::Result<()> {
Ok(stream) => break stream, Ok(stream) => break stream,
Err(err) => { Err(err) => {
log_error(&format!("connect error: {err}")); log_error(&format!("connect error: {err}"));
state.lock().unwrap().reconnecting();
tokio::select! { tokio::select! {
_ = &mut sigint => { _ = &mut sigint => {
log_error("received SIGINT"); log_error("received SIGINT");
@ -108,12 +107,10 @@ async fn main() -> io::Result<()> {
} }
}; };
let (mut read, mut write) = stream.into_split(); let (mut read, mut write) = stream.into_split();
state.lock().unwrap().reset();
let device_id = device_state::serialize_write(&WriteType::DeviceId(DEVICE_ID)).unwrap(); let device_id = device_state::serialize_write(&WriteType::DeviceId(DEVICE_ID)).unwrap();
if write.write_all(device_id.as_bytes()).await.is_err() { if write.write_all(device_id.as_bytes()).await.is_err() {
log_error("failed to send device id"); log_error("failed to send device id");
state.lock().unwrap().reconnecting();
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
continue; continue;
} }
@ -187,7 +184,6 @@ async fn main() -> io::Result<()> {
} }
log_error("reconnecting in 500ms"); log_error("reconnecting in 500ms");
state.lock().unwrap().reconnecting();
tokio::select! { tokio::select! {
_ = &mut sigint => { _ = &mut sigint => {
log_error("received SIGINT"); log_error("received SIGINT");

View file

@ -15,8 +15,7 @@ import {
export function UserInfo() { export function UserInfo() {
const { user } = useUser(); const { user } = useUser();
const { party, members, isConnecting, isReconnecting, resetParty } = const { party, members, isConnecting, isReconnecting } = useParty();
useParty();
return ( return (
<Item> <Item>
<ItemMedia> <ItemMedia>
@ -38,15 +37,8 @@ export function UserInfo() {
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
{party && ( {party && members.length > 1 && (
<Button <Button onClick={() => client.api.party.leave.post()}>Leave</Button>
onClick={async () => {
await client.api.party.leave.post();
resetParty();
}}
>
Leave
</Button>
)} )}
</ItemActions> </ItemActions>
</Item> </Item>

View file

@ -1,13 +1,4 @@
import { import { useCallback, useMemo, useState } from "react";
createContext,
createElement,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import type { import type {
PartySocketEvent, PartySocketEvent,
PartyState, PartyState,
@ -15,34 +6,14 @@ import type {
import { usePartySocket } from "./use-party-socket"; import { usePartySocket } from "./use-party-socket";
import { useUser } from "./user"; import { useUser } from "./user";
const emptyPartyState: PartyState = {
party: null,
members: [],
};
type PartyContextValue = PartyState & {
connectionState: "disconnected" | "connecting" | "connected" | "reconnecting";
isConnected: boolean;
isConnecting: boolean;
isReconnecting: boolean;
setPartyState: (state: PartyState) => void;
resetParty: () => void;
};
const PartyContext = createContext<PartyContextValue | null>(null);
function reducePartyState( function reducePartyState(
state: PartyState, state: PartyState,
event: PartySocketEvent, event: PartySocketEvent,
userId: string,
): PartyState { ): PartyState {
switch (event.type) { switch (event.type) {
case "party_status": { case "snapshot":
if (!event.party) return emptyPartyState; case "party_status":
const isMember = event.members.some((member) => member.userId === userId);
if (!isMember) return emptyPartyState;
return { party: event.party, members: event.members }; return { party: event.party, members: event.members };
}
case "member_payload": case "member_payload":
case "pong": case "pong":
case "error": case "error":
@ -57,18 +28,16 @@ function getApiUrl(): string | null {
return `${window.location.protocol}//${window.location.host}`; return `${window.location.protocol}//${window.location.host}`;
} }
export function PartyProvider({ children }: { children: ReactNode }) { export function useParty() {
const { user } = useUser(); const { session } = useUser();
const userId = user?.id ?? null; const [state, setState] = useState<PartyState>({
const [state, setState] = useState<PartyState>(emptyPartyState); party: null,
members: [],
});
const handleMessage = useCallback( const handleMessage = useCallback((event: PartySocketEvent) => {
(event: PartySocketEvent) => { setState((prev: PartyState) => reducePartyState(prev, event));
if (!userId) return; }, []);
setState((prev: PartyState) => reducePartyState(prev, event, userId));
},
[userId],
);
const apiUrl = useMemo(() => { const apiUrl = useMemo(() => {
const url = getApiUrl(); const url = getApiUrl();
@ -76,36 +45,13 @@ export function PartyProvider({ children }: { children: ReactNode }) {
return url; return url;
}, []); }, []);
const resetParty = useCallback(() => {
setState(emptyPartyState);
}, []);
useEffect(() => {
if (!userId) resetParty();
}, [resetParty, userId]);
const wsState = usePartySocket({ const wsState = usePartySocket({
apiUrl: userId ? apiUrl : null, apiUrl,
onMessage: userId ? handleMessage : null, onMessage: session ? handleMessage : null,
}); });
const value = useMemo( return {
() => ({ ...state,
...state, ...wsState,
...wsState, };
setPartyState: setState,
resetParty,
}),
[state, wsState, resetParty],
);
return createElement(PartyContext.Provider, { value }, children);
}
export function useParty() {
const context = useContext(PartyContext);
if (!context) {
throw new Error("useParty must be used within PartyProvider");
}
return context;
} }

View file

@ -1,8 +1,4 @@
import { treaty } from "@elysiajs/eden"; import { treaty } from "@elysiajs/eden";
import type { App } from "../../../api/src/index"; import type { App } from "../../../api/src/index";
// export const client = treaty<App>("aura.rpi1.danbulant.cloud", {}); export const client = treaty<App>("aura.rpi1.danbulant.cloud", {});
export const client = treaty<App>(
process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000",
{},
);

View file

@ -10,7 +10,6 @@ import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import type * as React from "react"; import type * as React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { PartyProvider } from "#/hooks/use-party";
import type { AuthSession } from "#/lib/auth.serverfn"; import type { AuthSession } from "#/lib/auth.serverfn";
import { fetchSession, sessionQueryKey } from "#/lib/auth-client"; import { fetchSession, sessionQueryKey } from "#/lib/auth-client";
import { client } from "#/lib/eden"; import { client } from "#/lib/eden";
@ -132,7 +131,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<HeadContent /> <HeadContent />
</head> </head>
<body className="font-sans antialiased wrap-anywhere dark"> <body className="font-sans antialiased wrap-anywhere dark">
<PartyProvider>{children}</PartyProvider> {children}
<Toaster /> <Toaster />
<TanStackDevtools <TanStackDevtools
config={{ config={{