Compare commits
2 commits
9e93f8b9f7
...
b14ac917d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b14ac917d6 | ||
|
|
2bcdb34515 |
14 changed files with 401 additions and 123 deletions
|
|
@ -6,11 +6,11 @@ import { syncApp } from "./routes/sync";
|
||||||
import "./workflows/sync";
|
import "./workflows/sync";
|
||||||
import "./workflows/party-analysis";
|
import "./workflows/party-analysis";
|
||||||
import "./dbos.ts";
|
import "./dbos.ts";
|
||||||
|
import { deviceClaimApp, deviceSocketApp } from "./routes/device-socket.ts";
|
||||||
import { partyApp } from "./routes/party";
|
import { partyApp } from "./routes/party";
|
||||||
import { partySocketApp, pubsub } from "./routes/party-socket";
|
import { partySocketApp, pubsub } from "./routes/party-socket";
|
||||||
import { quizRoutes } from "./routes/quiz.ts";
|
import { quizRoutes } from "./routes/quiz.ts";
|
||||||
import { statsApp } from "./routes/stats.ts";
|
import { statsApp } from "./routes/stats.ts";
|
||||||
import { deviceClaimApp, deviceSocketApp } from "./routes/device-socket.ts";
|
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.use(betterAuthElysia)
|
.use(betterAuthElysia)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import type { db } from "../db";
|
import type { db } from "../db";
|
||||||
import { topArtist as topArtistTable, topTrack as topTrackTable } from "../db/schema";
|
import {
|
||||||
|
topArtist as topArtistTable,
|
||||||
|
topTrack as topTrackTable,
|
||||||
|
} from "../db/schema";
|
||||||
import type { Question } from "../party-types";
|
import type { Question } from "../party-types";
|
||||||
import {
|
import {
|
||||||
buildQuestionWindow,
|
buildQuestionWindow,
|
||||||
|
|
@ -53,13 +56,20 @@ async function countTopTrackListeners({
|
||||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
||||||
const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name;
|
const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name;
|
||||||
if (!trackName || members.length === 0) return null;
|
if (!trackName || members.length === 0) return null;
|
||||||
const dbTrack = await db.query.track.findFirst({ where: { name: trackName } });
|
const dbTrack = await db.query.track.findFirst({
|
||||||
|
where: { name: trackName },
|
||||||
|
});
|
||||||
if (!dbTrack) return null;
|
if (!dbTrack) return null;
|
||||||
const memberIds = members.map((m) => m.userId);
|
const memberIds = members.map((m) => m.userId);
|
||||||
const entries = await db
|
const entries = await db
|
||||||
.select({ userId: topTrackTable.userId })
|
.select({ userId: topTrackTable.userId })
|
||||||
.from(topTrackTable)
|
.from(topTrackTable)
|
||||||
.where(and(eq(topTrackTable.trackId, dbTrack.id), inArray(topTrackTable.userId, memberIds)));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(topTrackTable.trackId, dbTrack.id),
|
||||||
|
inArray(topTrackTable.userId, memberIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
const correct = new Set(entries.map((e) => e.userId)).size;
|
const correct = new Set(entries.map((e) => e.userId)).size;
|
||||||
return {
|
return {
|
||||||
type: "numeric",
|
type: "numeric",
|
||||||
|
|
@ -77,13 +87,20 @@ async function countFavouriteArtistListeners({
|
||||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
||||||
const artistName = analytics?.storyClusters?.[0]?.artists?.[0]?.name;
|
const artistName = analytics?.storyClusters?.[0]?.artists?.[0]?.name;
|
||||||
if (!artistName || members.length === 0) return null;
|
if (!artistName || members.length === 0) return null;
|
||||||
const dbArtist = await db.query.artist.findFirst({ where: { name: artistName } });
|
const dbArtist = await db.query.artist.findFirst({
|
||||||
|
where: { name: artistName },
|
||||||
|
});
|
||||||
if (!dbArtist) return null;
|
if (!dbArtist) return null;
|
||||||
const memberIds = members.map((m) => m.userId);
|
const memberIds = members.map((m) => m.userId);
|
||||||
const entries = await db
|
const entries = await db
|
||||||
.select({ userId: topArtistTable.userId })
|
.select({ userId: topArtistTable.userId })
|
||||||
.from(topArtistTable)
|
.from(topArtistTable)
|
||||||
.where(and(eq(topArtistTable.artistId, dbArtist.id), inArray(topArtistTable.userId, memberIds)));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(topArtistTable.artistId, dbArtist.id),
|
||||||
|
inArray(topArtistTable.userId, memberIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
const correct = new Set(entries.map((e) => e.userId)).size;
|
const correct = new Set(entries.map((e) => e.userId)).size;
|
||||||
return {
|
return {
|
||||||
type: "numeric",
|
type: "numeric",
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,10 @@ export function buildMemberPairOptions(
|
||||||
const pairs: string[] = [correctPair];
|
const pairs: string[] = [correctPair];
|
||||||
for (let i = 0; i < members.length; i++) {
|
for (let i = 0; i < members.length; i++) {
|
||||||
for (let j = i + 1; j < members.length; j++) {
|
for (let j = i + 1; j < members.length; j++) {
|
||||||
const pair = `${members[i]!.name} & ${members[j]!.name}`;
|
const left = members[i];
|
||||||
|
const right = members[j];
|
||||||
|
if (!left || !right) continue;
|
||||||
|
const pair = `${left.name} & ${right.name}`;
|
||||||
if (pair !== correctPair) pairs.push(pair);
|
if (pair !== correctPair) pairs.push(pair);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { db as Db } from "../db";
|
import type { db as Db } from "../db";
|
||||||
import { party } from "../db/schema";
|
import { party } from "../db/schema";
|
||||||
import type { QuizState } from "../party-types";
|
import type { PartySocketEvent, QuizState } from "../party-types";
|
||||||
|
import { publishDeviceEventForUser } from "../routes/device-socket";
|
||||||
import { pubsub } from "../routes/party-socket";
|
import { pubsub } from "../routes/party-socket";
|
||||||
|
|
||||||
export async function updatePartyData(
|
export async function updatePartyData(
|
||||||
|
|
@ -32,6 +33,19 @@ export async function updatePartyData(
|
||||||
},
|
},
|
||||||
members,
|
members,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const event: PartySocketEvent = {
|
||||||
|
type: "party_status",
|
||||||
|
party: {
|
||||||
|
...partyObject,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
members,
|
||||||
|
};
|
||||||
|
for (const member of members) {
|
||||||
|
if (!member.userId) continue;
|
||||||
|
void publishDeviceEventForUser(member.userId, event);
|
||||||
|
}
|
||||||
await db
|
await db
|
||||||
.update(party)
|
.update(party)
|
||||||
.set({
|
.set({
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import Elysia from "elysia";
|
import Elysia from "elysia";
|
||||||
import { betterAuthElysia } from "../auth";
|
import { betterAuthElysia } from "../auth";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { deviceConnection } from "../db/schema";
|
import { deviceConnection } from "../db/schema";
|
||||||
import { getMemberRecord } from "../party-data";
|
import { getMemberRecord } from "../party-data";
|
||||||
import type { PartySocketEvent } from "../party-types";
|
import type { PartySocketEvent, QuizState } from "../party-types";
|
||||||
import { pubsub, topic } from "./party-socket";
|
import { pubsub, topic } from "./party-socket";
|
||||||
|
|
||||||
type DeviceSocketMessage =
|
type DeviceSocketMessage =
|
||||||
|
|
@ -12,6 +13,10 @@ type DeviceSocketMessage =
|
||||||
| { type: "hello" }
|
| { type: "hello" }
|
||||||
| { type: "device_event"; deviceId: string; event: PartySocketEvent };
|
| { type: "device_event"; deviceId: string; event: PartySocketEvent };
|
||||||
|
|
||||||
|
type DeviceQuizResponsePayload = {
|
||||||
|
QuizResponse: number;
|
||||||
|
};
|
||||||
|
|
||||||
let devProxySocket: WebSocket | null = null;
|
let devProxySocket: WebSocket | null = null;
|
||||||
|
|
||||||
function isDeviceMessage(
|
function isDeviceMessage(
|
||||||
|
|
@ -25,6 +30,29 @@ function isDeviceMessage(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDeviceQuizResponsePayload(
|
||||||
|
value: unknown,
|
||||||
|
): value is DeviceQuizResponsePayload {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"QuizResponse" in value &&
|
||||||
|
Number.isInteger((value as DeviceQuizResponsePayload).QuizResponse)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendDeviceEvent(deviceId: string, event: PartySocketEvent) {
|
||||||
|
if (!devProxySocket || devProxySocket.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
devProxySocket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "device_event",
|
||||||
|
deviceId,
|
||||||
|
event,
|
||||||
|
} satisfies DeviceSocketMessage),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function claimDeviceForUser(deviceId: string, userId: string) {
|
export async function claimDeviceForUser(deviceId: string, userId: string) {
|
||||||
await db
|
await db
|
||||||
.insert(deviceConnection)
|
.insert(deviceConnection)
|
||||||
|
|
@ -55,29 +83,72 @@ export async function publishDeviceEventForUser(
|
||||||
.where(eq(deviceConnection.userId, userId));
|
.where(eq(deviceConnection.userId, userId));
|
||||||
|
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
devProxySocket.send(
|
sendDeviceEvent(device.id, event);
|
||||||
JSON.stringify({
|
|
||||||
type: "device_event",
|
|
||||||
deviceId: device.id,
|
|
||||||
event,
|
|
||||||
} satisfies DeviceSocketMessage),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forwardDevicePayload(deviceId: string, payload: unknown) {
|
async function forwardDevicePayload(deviceId: string, payload: unknown) {
|
||||||
|
if (!isDeviceQuizResponsePayload(payload)) {
|
||||||
|
sendDeviceEvent(deviceId, {
|
||||||
|
type: "error",
|
||||||
|
message: "Unsupported device payload.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const device = await db
|
const device = await db
|
||||||
.select()
|
.select()
|
||||||
.from(deviceConnection)
|
.from(deviceConnection)
|
||||||
.where(eq(deviceConnection.id, deviceId))
|
.where(eq(deviceConnection.id, deviceId))
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => rows[0]);
|
||||||
if (!device) return;
|
if (!device) {
|
||||||
|
sendDeviceEvent(deviceId, {
|
||||||
|
type: "error",
|
||||||
|
message: "Device not linked to a user.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const membership = await getMemberRecord(db, device.userId);
|
const membership = await getMemberRecord(db, device.userId);
|
||||||
if (!membership) return;
|
if (!membership) {
|
||||||
|
sendDeviceEvent(deviceId, {
|
||||||
|
type: "error",
|
||||||
|
message: "Device not linked to a party member.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payloadString = JSON.stringify(payload);
|
const party = await db.query.party.findFirst({
|
||||||
if (payloadString.length > 8_000) return;
|
where: { id: membership.partyId },
|
||||||
|
});
|
||||||
|
if (!party) {
|
||||||
|
sendDeviceEvent(deviceId, {
|
||||||
|
type: "error",
|
||||||
|
message: "Party not found.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const quizData = party.data as QuizState | null;
|
||||||
|
if (!quizData || quizData.status !== "running") {
|
||||||
|
sendDeviceEvent(deviceId, {
|
||||||
|
type: "error",
|
||||||
|
message: "Quiz not running.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!quizData.workflowId) {
|
||||||
|
sendDeviceEvent(deviceId, {
|
||||||
|
type: "error",
|
||||||
|
message: "Workflow ID not found.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DBOS.send(
|
||||||
|
quizData.workflowId,
|
||||||
|
{ playerId: device.userId, selected: payload.QuizResponse },
|
||||||
|
"quiz_responses",
|
||||||
|
);
|
||||||
|
|
||||||
pubsub.publish(
|
pubsub.publish(
|
||||||
topic.party(membership.partyId),
|
topic.party(membership.partyId),
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ export const pubsub = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function broadcastQuizState(ws: any, partyId: string) {
|
export async function broadcastQuizState(
|
||||||
|
ws: { publish: (topic: string, message: string) => void },
|
||||||
|
partyId: string,
|
||||||
|
) {
|
||||||
const partyRecord = await db.query.party.findFirst({
|
const partyRecord = await db.query.party.findFirst({
|
||||||
where: { id: partyId },
|
where: { id: partyId },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -331,7 +331,10 @@ export async function seedPartyWithThreeDiverseUsers(): Promise<{
|
||||||
const user = await createUser(`Diverse User ${i}`);
|
const user = await createUser(`Diverse User ${i}`);
|
||||||
userIds.push(user.id);
|
userIds.push(user.id);
|
||||||
|
|
||||||
const genre = genres[i]!;
|
const genre = genres[i];
|
||||||
|
if (!genre) {
|
||||||
|
throw new Error("Missing genre");
|
||||||
|
}
|
||||||
const artist = await createArtist(`artist-${i}-${randomUUID()}`, [
|
const artist = await createArtist(`artist-${i}-${randomUUID()}`, [
|
||||||
genre.id,
|
genre.id,
|
||||||
]);
|
]);
|
||||||
|
|
@ -349,7 +352,10 @@ export async function seedPartyWithThreeDiverseUsers(): Promise<{
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create party with first user as host
|
// Create party with first user as host
|
||||||
const hostId = userIds[0]!;
|
const hostId = userIds[0];
|
||||||
|
if (!hostId) {
|
||||||
|
throw new Error("Missing host user");
|
||||||
|
}
|
||||||
const party = await createParty(hostId);
|
const party = await createParty(hostId);
|
||||||
|
|
||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
|
|
@ -358,8 +364,14 @@ export async function seedPartyWithThreeDiverseUsers(): Promise<{
|
||||||
|
|
||||||
// Give each user their unique track
|
// Give each user their unique track
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await addTopTrack(userIds[i]!, tracks[i]!.id, 1);
|
const userId = userIds[i];
|
||||||
await addTopArtist(userIds[i]!, artists[i]!.id, 1);
|
const track = tracks[i];
|
||||||
|
const artist = artists[i];
|
||||||
|
if (!userId || !track || !artist) {
|
||||||
|
throw new Error("Missing seeded test data");
|
||||||
|
}
|
||||||
|
await addTopTrack(userId, track.id, 1);
|
||||||
|
await addTopArtist(userId, artist.id, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ let pool: Pool | null = null;
|
||||||
|
|
||||||
const getPool = (): Pool => {
|
const getPool = (): Pool => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
pool = new Pool({ connectionString: url! });
|
pool = new Pool({ connectionString: url });
|
||||||
}
|
}
|
||||||
return pool;
|
return pool;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/** biome-ignore-all lint/style/noNonNullAssertion: <explanation> */
|
/** biome-ignore-all lint/style/noNonNullAssertion: test setup uses controlled arrays */
|
||||||
import { DBOS } from "@dbos-inc/dbos-sdk";
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,55 @@ type ApiEnvelope =
|
||||||
| { type: "hello" }
|
| { type: "hello" }
|
||||||
| { type: "device_event"; deviceId: string; event: unknown };
|
| { type: "device_event"; deviceId: string; event: unknown };
|
||||||
|
|
||||||
|
type DeviceMessage = {
|
||||||
|
DeviceId: string;
|
||||||
|
} | {
|
||||||
|
QuizResponse: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceQuestionData = {
|
||||||
|
text: string;
|
||||||
|
points: number;
|
||||||
|
index: number;
|
||||||
|
q_type: "Choice" | { Numeric: { min: number; max: number } }
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuizQuestion =
|
||||||
|
| {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartyStatusEvent = {
|
||||||
|
type: "party_status";
|
||||||
|
party: { data?: QuizState } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuizStateEvent = {
|
||||||
|
type: "quiz_state";
|
||||||
|
quiz: QuizState;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorEvent = {
|
||||||
|
type: "error";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartySocketEvent = PartyStatusEvent | QuizStateEvent | ErrorEvent;
|
||||||
|
|
||||||
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 apiSocket = new WebSocket("ws://localhost:4000/api/dev-socket/ws");
|
const apiSocket = new WebSocket("ws://localhost:4000/api/dev-socket/ws");
|
||||||
|
|
@ -16,35 +65,66 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
data(socket, buf) {
|
},
|
||||||
const raw = new TextDecoder().decode(buf).trim();
|
data(socket, buf) {
|
||||||
if (!raw) return;
|
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;
|
||||||
|
|
||||||
const currentDeviceId = socketDeviceId(socket);
|
if ("DeviceId" in data) {
|
||||||
if (!currentDeviceId) {
|
registerSocket(socket, data.DeviceId);
|
||||||
registerSocket(socket, raw);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if ("QuizResponse" in data) {
|
||||||
|
const deviceId = socketDeviceId(socket);
|
||||||
apiSocket?.send(
|
if (!deviceId) return;
|
||||||
JSON.stringify({
|
apiSocket?.send(
|
||||||
type: "device_message",
|
JSON.stringify({
|
||||||
deviceId: currentDeviceId,
|
type: "device_message",
|
||||||
payload: raw,
|
deviceId,
|
||||||
}),
|
payload: { QuizResponse: data.QuizResponse },
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
close(socket) {
|
close(socket) {
|
||||||
|
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);
|
||||||
|
|
@ -64,9 +144,35 @@ apiSocket.onmessage = (e) => {
|
||||||
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) return;
|
if (!socket) return;
|
||||||
|
const event = message.event as PartySocketEvent;
|
||||||
|
if (event.type === "error") {
|
||||||
|
socket.write(`${JSON.stringify({ Error: event.message })}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "party_status") {
|
||||||
|
const quizData = event.party?.data ?? null;
|
||||||
|
if (!quizData) return;
|
||||||
|
const question = toDeviceQuestionData(quizData);
|
||||||
|
socket.write(
|
||||||
|
`${JSON.stringify({ Question: question, Status: quizData.status })}\n`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "quiz_state") {
|
||||||
|
const question = toDeviceQuestionData(event.quiz);
|
||||||
|
socket.write(
|
||||||
|
`${JSON.stringify({ Question: question, Status: event.quiz.status })}\n`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
socket.write(`${JSON.stringify(message.event)}\n`);
|
socket.write(`${JSON.stringify(message.event)}\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
apiSocket.onerror = (error) => {
|
apiSocket.onerror = (error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
21
esp32/Cargo.lock
generated
21
esp32/Cargo.lock
generated
|
|
@ -1019,6 +1019,8 @@ dependencies = [
|
||||||
"owned_str",
|
"owned_str",
|
||||||
"panic-probe",
|
"panic-probe",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"static_cell",
|
"static_cell",
|
||||||
"ufmt 0.2.0",
|
"ufmt 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
@ -1698,6 +1700,19 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_yaml"
|
name = "serde_yaml"
|
||||||
version = "0.9.34+deprecated"
|
version = "0.9.34+deprecated"
|
||||||
|
|
@ -2178,3 +2193,9 @@ dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ codegen-units = 1
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
[target.'cfg(target_arch = "xtensa")'.dependencies]
|
[target.'cfg(target_arch = "xtensa")'.dependencies]
|
||||||
|
serde = { version = "1.0.228", default-features = false, features = ["derive", "alloc"] }
|
||||||
|
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
|
||||||
arrayvec = { version = "0.7.6", default-features = false }
|
arrayvec = { version = "0.7.6", default-features = false }
|
||||||
ag-lcd = { version = "0.3", features = ["ufmt"] }
|
ag-lcd = { version = "0.3", features = ["ufmt"] }
|
||||||
as5600 = "0.8.0"
|
as5600 = "0.8.0"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use core::str::FromStr;
|
||||||
|
|
||||||
use embassy_executor::Spawner;
|
use embassy_executor::Spawner;
|
||||||
use embassy_futures::select::{Either, select};
|
use embassy_futures::select::{Either, select};
|
||||||
use embassy_net::tcp::{TcpReader, TcpWriter};
|
use embassy_net::tcp::{State, TcpReader, TcpWriter};
|
||||||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||||
use embassy_sync::mutex::Mutex;
|
use embassy_sync::mutex::Mutex;
|
||||||
use embassy_sync::signal::Signal;
|
use embassy_sync::signal::Signal;
|
||||||
|
|
@ -21,6 +21,7 @@ use esp_hal::{
|
||||||
};
|
};
|
||||||
use esp_println::println;
|
use esp_println::println;
|
||||||
use owned_str::OwnedStr;
|
use owned_str::OwnedStr;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use ufmt::uwrite;
|
use ufmt::uwrite;
|
||||||
|
|
||||||
mod buffer;
|
mod buffer;
|
||||||
|
|
@ -41,6 +42,7 @@ const WHEEL_PRECISION: i32 = 32;
|
||||||
const WHEEL_INVERTED: bool = false;
|
const WHEEL_INVERTED: bool = false;
|
||||||
const DEVICE_ID: &str = "esp32-1";
|
const DEVICE_ID: &str = "esp32-1";
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
enum QuestionType {
|
enum QuestionType {
|
||||||
Choice,
|
Choice,
|
||||||
Numeric { min: i32, max: i32 },
|
Numeric { min: i32, max: i32 },
|
||||||
|
|
@ -58,7 +60,33 @@ struct QuestionData {
|
||||||
text: OwnedStr<256>,
|
text: OwnedStr<256>,
|
||||||
q_type: QuestionType,
|
q_type: QuestionType,
|
||||||
points: i32,
|
points: i32,
|
||||||
generation: usize,
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct QuestionDataNet<'a> {
|
||||||
|
text: &'a str,
|
||||||
|
q_type: QuestionType,
|
||||||
|
points: i32,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
enum ProxyOutput<'a> {
|
||||||
|
Question(QuestionDataNet<'a>),
|
||||||
|
Results,
|
||||||
|
Error(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<QuestionDataNet<'a>> for QuestionData {
|
||||||
|
fn from(value: QuestionDataNet<'a>) -> Self {
|
||||||
|
QuestionData {
|
||||||
|
text: OwnedStr::from_str(value.text).unwrap(),
|
||||||
|
q_type: value.q_type,
|
||||||
|
points: value.points,
|
||||||
|
index: value.index,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
|
|
@ -69,6 +97,14 @@ struct WheelData {
|
||||||
accumulated: i32,
|
accumulated: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum MainState {
|
||||||
|
Loading,
|
||||||
|
Question,
|
||||||
|
Results,
|
||||||
|
}
|
||||||
|
|
||||||
|
static MAIN_STATE: Mutex<CriticalSectionRawMutex, MainState> = Mutex::new(MainState::Loading);
|
||||||
static QUESTION: Mutex<CriticalSectionRawMutex, Option<QuestionData>> = Mutex::new(None);
|
static QUESTION: Mutex<CriticalSectionRawMutex, Option<QuestionData>> = Mutex::new(None);
|
||||||
static QUESTION_UPDATE: Signal<CriticalSectionRawMutex, ()> = Signal::new();
|
static QUESTION_UPDATE: Signal<CriticalSectionRawMutex, ()> = Signal::new();
|
||||||
static WHEEL_VALUE: Mutex<CriticalSectionRawMutex, WheelData> = Mutex::new(WheelData {
|
static WHEEL_VALUE: Mutex<CriticalSectionRawMutex, WheelData> = Mutex::new(WheelData {
|
||||||
|
|
@ -99,7 +135,6 @@ pub async fn tcp_read_loop(
|
||||||
cancel: &Signal<CriticalSectionRawMutex, ()>,
|
cancel: &Signal<CriticalSectionRawMutex, ()>,
|
||||||
) -> Result<(), TcpDisconnect> {
|
) -> Result<(), TcpDisconnect> {
|
||||||
let mut buf = [0u8; 1024];
|
let mut buf = [0u8; 1024];
|
||||||
let mut generation = 1;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let read_fut = read.read(&mut buf);
|
let read_fut = read.read(&mut buf);
|
||||||
|
|
@ -120,7 +155,6 @@ pub async fn tcp_read_loop(
|
||||||
let Ok(str) = core::str::from_utf8(&buf[..len]) else {
|
let Ok(str) = core::str::from_utf8(&buf[..len]) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let mut counter = 0;
|
|
||||||
let mut question_data = None;
|
let mut question_data = None;
|
||||||
let mut future_wheel = WheelData {
|
let mut future_wheel = WheelData {
|
||||||
value: 0,
|
value: 0,
|
||||||
|
|
@ -128,89 +162,61 @@ pub async fn tcp_read_loop(
|
||||||
max: 0,
|
max: 0,
|
||||||
accumulated: 0,
|
accumulated: 0,
|
||||||
};
|
};
|
||||||
for line in str.lines() {
|
if let Some(last) = str.lines().last() {
|
||||||
if line == "##" {
|
let Ok(data) = serde_json::from_str::<ProxyOutput>(last) else {
|
||||||
overwrite_lcd("Waiting", DEVICE_ID).await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if line == "$$" {
|
|
||||||
counter = 1;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
if counter == 1 {
|
match data {
|
||||||
let mut q_type = QuestionType::Choice;
|
ProxyOutput::Question(data) => {
|
||||||
let mut points = -1;
|
let data: QuestionData = data.into();
|
||||||
|
match data.q_type {
|
||||||
for pairs in line.split(' ') {
|
QuestionType::Numeric { min, max } => {
|
||||||
let (key, value) = pairs.split_once('=').unwrap();
|
future_wheel.max = max;
|
||||||
if key == "type" {
|
future_wheel.min = min;
|
||||||
q_type = if value == "choice" {
|
future_wheel.value = (min + max) / 2;
|
||||||
QuestionType::Choice
|
|
||||||
} else {
|
|
||||||
QuestionType::Numeric { min: -1, max: -1 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if key == "points" {
|
|
||||||
points = value.parse().unwrap();
|
|
||||||
}
|
|
||||||
if key == "rangeMin" || key == "rangeMax" {
|
|
||||||
match q_type {
|
|
||||||
QuestionType::Choice => {}
|
|
||||||
QuestionType::Numeric {
|
|
||||||
ref mut min,
|
|
||||||
ref mut max,
|
|
||||||
} => {
|
|
||||||
if key == "rangeMin" {
|
|
||||||
*min = value.parse().unwrap();
|
|
||||||
future_wheel.min = *min;
|
|
||||||
} else {
|
|
||||||
*max = value.parse().unwrap();
|
|
||||||
future_wheel.max = *max;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
_ => {}
|
||||||
|
};
|
||||||
|
question_data = Some(data);
|
||||||
}
|
}
|
||||||
|
ProxyOutput::Results => {
|
||||||
if let QuestionType::Numeric { min, max } = q_type {
|
*MAIN_STATE.lock().await = MainState::Results;
|
||||||
let diff = max - min;
|
|
||||||
future_wheel.value = min + diff / 2;
|
|
||||||
future_wheel.accumulated = 0;
|
|
||||||
}
|
}
|
||||||
|
ProxyOutput::Error(e) => {}
|
||||||
question_data = Some(QuestionData {
|
|
||||||
text: OwnedStr::new(),
|
|
||||||
q_type,
|
|
||||||
points,
|
|
||||||
generation,
|
|
||||||
});
|
|
||||||
counter = 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if counter == 2 {
|
|
||||||
question_data.as_mut().unwrap().text = OwnedStr::from_str(line).unwrap();
|
|
||||||
generation += 1;
|
|
||||||
counter = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(question_data) = question_data {
|
if let Some(question_data) = question_data {
|
||||||
*QUESTION.lock().await = Some(question_data);
|
*QUESTION.lock().await = Some(question_data);
|
||||||
|
*MAIN_STATE.lock().await = MainState::Question;
|
||||||
*WHEEL_VALUE.lock().await = future_wheel;
|
*WHEEL_VALUE.lock().await = future_wheel;
|
||||||
QUESTION_UPDATE.signal(());
|
QUESTION_UPDATE.signal(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
enum WriteType<'a> {
|
||||||
|
QuizResponse(i32),
|
||||||
|
DeviceId(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn tcp_write_loop(
|
pub async fn tcp_write_loop(
|
||||||
mut write: TcpWriter<'_>,
|
mut write: TcpWriter<'_>,
|
||||||
cancel: &Signal<CriticalSectionRawMutex, ()>,
|
cancel: &Signal<CriticalSectionRawMutex, ()>,
|
||||||
) -> Result<(), TcpDisconnect> {
|
) -> Result<(), TcpDisconnect> {
|
||||||
if write.write(DEVICE_ID.as_bytes()).await.is_err() {
|
if write
|
||||||
|
.write(
|
||||||
|
serde_json::to_string(&WriteType::DeviceId(DEVICE_ID))
|
||||||
|
.unwrap()
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
cancel.signal(());
|
cancel.signal(());
|
||||||
return Err(TcpDisconnect::WriteError);
|
return Err(TcpDisconnect::WriteError);
|
||||||
}
|
}
|
||||||
let mut buffer = buffer::WriteBuffer::<256>::new();
|
|
||||||
loop {
|
loop {
|
||||||
let input_fut = input::INPUT.receive();
|
let input_fut = input::INPUT.receive();
|
||||||
let cancel_fut = cancel.wait();
|
let cancel_fut = cancel.wait();
|
||||||
|
|
@ -219,14 +225,24 @@ pub async fn tcp_write_loop(
|
||||||
Either::Second(()) => return Err(TcpDisconnect::Cancelled),
|
Either::Second(()) => return Err(TcpDisconnect::Cancelled),
|
||||||
};
|
};
|
||||||
println!("button={}", data);
|
println!("button={}", data);
|
||||||
let angle = *ANGLE.lock().await;
|
let value = {
|
||||||
core::writeln!(buffer, "button={} angle={}", data, angle).ok();
|
let question = QUESTION.lock().await;
|
||||||
|
let wheel = *WHEEL_VALUE.lock().await;
|
||||||
|
match question.as_ref() {
|
||||||
|
Some(q) => match q.q_type {
|
||||||
|
QuestionType::Numeric { .. } => wheel.value,
|
||||||
|
QuestionType::Choice => data as _,
|
||||||
|
},
|
||||||
|
_ => data as _,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let data = WriteType::QuizResponse(value);
|
||||||
|
let buffer = serde_json::to_string(&data).unwrap();
|
||||||
println!("write: {}", &buffer);
|
println!("write: {}", &buffer);
|
||||||
if write.write(&buffer).await.is_err() {
|
if write.write(buffer.as_bytes()).await.is_err() {
|
||||||
cancel.signal(());
|
cancel.signal(());
|
||||||
return Err(TcpDisconnect::WriteError);
|
return Err(TcpDisconnect::WriteError);
|
||||||
}
|
}
|
||||||
buffer.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,19 +252,32 @@ const DOT: char = char::from_u32(0b1010_0101).unwrap();
|
||||||
|
|
||||||
#[embassy_executor::task]
|
#[embassy_executor::task]
|
||||||
pub async fn main_loop() {
|
pub async fn main_loop() {
|
||||||
let mut last_gen = 0;
|
let mut last_index = 0;
|
||||||
let mut title_offset = 0;
|
let mut title_offset = 0;
|
||||||
println!("Main loop started");
|
println!("Main loop started");
|
||||||
loop {
|
loop {
|
||||||
embassy_time::Timer::after_millis(50).await;
|
embassy_time::Timer::after_millis(50).await;
|
||||||
|
let state = *MAIN_STATE.lock().await;
|
||||||
|
|
||||||
|
match state {
|
||||||
|
MainState::Loading => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
MainState::Question => {}
|
||||||
|
MainState::Results => {
|
||||||
|
overwrite_lcd("Results", "").await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let wheel = *WHEEL_VALUE.lock().await;
|
let wheel = *WHEEL_VALUE.lock().await;
|
||||||
let question = QUESTION.lock().await;
|
let question = QUESTION.lock().await;
|
||||||
let Some(question) = question.as_ref() else {
|
let Some(question) = question.as_ref() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
title_offset += 1;
|
title_offset += 1;
|
||||||
if question.generation != last_gen {
|
if question.index != last_index {
|
||||||
last_gen = question.generation;
|
last_index = question.index;
|
||||||
title_offset = 0;
|
title_offset = 0;
|
||||||
}
|
}
|
||||||
let title_line = if question.text.len() > 16 {
|
let title_line = if question.text.len() > 16 {
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ pub async fn overwrite_lcd(line1: &str, line2: &str) {
|
||||||
let mut buffer = SCREEN_BUFFER.lock().await;
|
let mut buffer = SCREEN_BUFFER.lock().await;
|
||||||
buffer.line1_ptr = 0;
|
buffer.line1_ptr = 0;
|
||||||
buffer.line2_ptr = 0;
|
buffer.line2_ptr = 0;
|
||||||
buffer.line1.fill(0);
|
buffer.line1.fill(32);
|
||||||
buffer.line2.fill(0);
|
buffer.line2.fill(32);
|
||||||
let len1 = line1.len().min(buffer.line1.len());
|
let len1 = line1.len().min(buffer.line1.len());
|
||||||
let len2 = line2.len().min(buffer.line2.len());
|
let len2 = line2.len().min(buffer.line2.len());
|
||||||
buffer.line1[..len1].copy_from_slice(line1[..len1].as_bytes());
|
buffer.line1[..len1].copy_from_slice(line1[..len1].as_bytes());
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue