From 4de0d86d437641e831495fd133b51ebdfccfa225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Thu, 15 Oct 2026 23:09:22 +0000 Subject: [PATCH] Tell a client where to watch a printer's camera `continuum-proxy` now generates go2rtc's configuration from the camera URLs the printers themselves report, and sends two new things up: a `stream_base_url` on its hello, and a `camera_stream` per printer in telemetry. This is the other end of both. `GET /printers/:id/stream` answers `{ streamUrl, cameraId }`, which is everything a browser needs to open a WebRTC session against the gateway's go2rtc. 404 rather than 200-with-nulls when either half is missing: a client handed `{ streamUrl: null }` has to remember to check, and one that forgets renders a player that can never load. The two causes get different messages, because they are different problems for whoever is debugging - a P1/A1 that can never stream is not a gateway that happens to be offline - and the printer is checked first so a disconnected gateway cannot mask the permanent case. The resolution lives in `src/stream.ts` with no imports at all, for the same reason `dispatch/job_status.ts` does: `../db` opens a connection at import time, and these two messages are exactly the part worth testing without one. Storage, both decided against the obvious alternative: - `stream_base_url` lives on the in-memory connection, so it goes away with the socket. It is a LAN address that is only reachable while that gateway is up; a column on `farms` would keep handing out an address nothing answers after the gateway disconnected or moved. - `camera_stream` gets a plain column, because every sibling telemetry field already has one, and it is cleared when absent like the rest - a printer that loses its stream must stop being offered one. `cameraStream` is also added to `printerProjection`, which that file deliberately requires someone to decide per column. It is a go2rtc stream name, which is our own printer id echoed back, not a credential, and having it in the list a client already fetches is what saves it from calling the stream endpoint once per printer to discover that most of them 404 by design. Worth writing down, and it is in the route's comment too: `streamUrl` is a LAN address and nothing relays it. A browser on the gateway's network can play the stream and one outside cannot, and this endpoint cannot tell which the caller is. Good enough for an operator in the workshop, a real limit for anyone else, and a relay is the fix that does not exist yet. 85 tests (was 77), `bunx tsc --noEmit` clean. --- drizzle/0002_lucky_stepford_cuckoos.sql | 1 + drizzle/meta/0002_snapshot.json | 715 ++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/projections.ts | 7 + src/db/schema/printers.ts | 9 + src/routes/printers.ts | 36 +- src/routes/ws/edge.ts | 24 + src/stream.ts | 29 + tests/stream.test.ts | 62 ++ 9 files changed, 889 insertions(+), 1 deletion(-) create mode 100644 drizzle/0002_lucky_stepford_cuckoos.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/stream.ts create mode 100644 tests/stream.test.ts diff --git a/drizzle/0002_lucky_stepford_cuckoos.sql b/drizzle/0002_lucky_stepford_cuckoos.sql new file mode 100644 index 0000000..04fc91a --- /dev/null +++ b/drizzle/0002_lucky_stepford_cuckoos.sql @@ -0,0 +1 @@ +ALTER TABLE "printers" ADD COLUMN "camera_stream" text; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..e8672de --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,715 @@ +{ + "id": "95497aac-876c-40e4-9683-f7a177fa5177", + "prevId": "55f32571-d0c7-46a5-b763-ea7bef84faeb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.org_members": { + "name": "org_members", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_members_user_id_idx": { + "name": "org_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_members_org_id_organisations_id_fk": { + "name": "org_members_org_id_organisations_id_fk", + "tableFrom": "org_members", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_members_org_id_user_id_pk": { + "name": "org_members_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organisations": { + "name": "organisations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.farms": { + "name": "farms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "edge_proxy_token": { + "name": "edge_proxy_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "farms_org_id_idx": { + "name": "farms_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "farms_org_id_organisations_id_fk": { + "name": "farms_org_id_organisations_id_fk", + "tableFrom": "farms", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.printers": { + "name": "printers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "printer_vendor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "printer_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "lan_address": { + "name": "lan_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serial_number": { + "name": "serial_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_code": { + "name": "access_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_printer_id": { + "name": "gateway_printer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nozzle_temp_c": { + "name": "nozzle_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "nozzle_target_temp_c": { + "name": "nozzle_target_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bed_temp_c": { + "name": "bed_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bed_target_temp_c": { + "name": "bed_target_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "progress_percent": { + "name": "progress_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_layer": { + "name": "current_layer", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_layers": { + "name": "total_layers", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining_minutes": { + "name": "remaining_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_job_name": { + "name": "current_job_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_alerts": { + "name": "active_alerts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "camera_stream": { + "name": "camera_stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_state": { + "name": "vendor_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_error_code": { + "name": "vendor_error_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "printers_farm_gateway_printer_id_idx": { + "name": "printers_farm_gateway_printer_id_idx", + "columns": [ + { + "expression": "farm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gateway_printer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "printers_farm_id_farms_id_fk": { + "name": "printers_farm_id_farms_id_fk", + "tableFrom": "printers", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.print_jobs": { + "name": "print_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "print_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "progress_pct": { + "name": "progress_pct", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "estimated_time_s": { + "name": "estimated_time_s", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "elapsed_time_s": { + "name": "elapsed_time_s", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "submitted_by": { + "name": "submitted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "print_jobs_farm_id_farms_id_fk": { + "name": "print_jobs_farm_id_farms_id_fk", + "tableFrom": "print_jobs", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "print_jobs_printer_id_printers_id_fk": { + "name": "print_jobs_printer_id_printers_id_fk", + "tableFrom": "print_jobs", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.frame_embeddings": { + "name": "frame_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "print_job_id": { + "name": "print_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(512)", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "frame_embeddings_embedding_ivfflat_idx": { + "name": "frame_embeddings_embedding_ivfflat_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "ivfflat", + "with": { + "lists": 100 + } + } + }, + "foreignKeys": { + "frame_embeddings_print_job_id_print_jobs_id_fk": { + "name": "frame_embeddings_print_job_id_print_jobs_id_fk", + "tableFrom": "frame_embeddings", + "tableTo": "print_jobs", + "columnsFrom": [ + "print_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frame_embeddings_printer_id_printers_id_fk": { + "name": "frame_embeddings_printer_id_printers_id_fk", + "tableFrom": "frame_embeddings", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "viewer", + "operator", + "owner" + ] + }, + "public.printer_status": { + "name": "printer_status", + "schema": "public", + "values": [ + "offline", + "idle", + "printing", + "paused", + "error", + "maintenance" + ] + }, + "public.printer_vendor": { + "name": "printer_vendor", + "schema": "public", + "values": [ + "bambu", + "prusa", + "klipper" + ] + }, + "public.print_job_status": { + "name": "print_job_status", + "schema": "public", + "values": [ + "queued", + "uploading", + "printing", + "paused", + "completed", + "failed", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 7643a1f..238d6e9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1791041832172, "tag": "0001_luxuriant_jane_foster", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1792105504827, + "tag": "0002_lucky_stepford_cuckoos", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/projections.ts b/src/db/projections.ts index dc5fbb5..8e3c1a4 100644 --- a/src/db/projections.ts +++ b/src/db/projections.ts @@ -70,6 +70,13 @@ export const printerProjection = projection(printers, [ "remainingMinutes", "currentJobName", "activeAlerts", + // Public on purpose, which this list requires someone to decide: it is a + // go2rtc stream name, which is our own printer id echoed back, not a + // credential. It is here so a client can tell which printers have a camera + // from the list it already fetched - without it, finding out means a + // `GET /printers/:id/stream` per printer, and in a mixed fleet most of + // those 404 by design. + "cameraStream", "vendorState", "vendorErrorCode", "metadata", diff --git a/src/db/schema/printers.ts b/src/db/schema/printers.ts index 828c207..e38717b 100644 --- a/src/db/schema/printers.ts +++ b/src/db/schema/printers.ts @@ -62,6 +62,15 @@ export const printers = pgTable( * tables, which Continuum does not have. */ activeAlerts: integer("active_alerts"), + /** + * The go2rtc stream name the gateway can serve this printer's camera + * under, or null when it cannot. A plain column because every sibling + * telemetry field above is one; a JSON snapshot column would have been + * the odd one out. Null is the common case, not a fault: only Bambu's + * X1/H2 class publishes an RTSP URL, a P1/A1 has a camera but no way to + * restream it. + */ + cameraStream: text("camera_stream"), /** * The printer's own state string, kept alongside the narrowed * `status` enum. Worth storing both: the enum is what the UI filters diff --git a/src/routes/printers.ts b/src/routes/printers.ts index 31415ff..b59cfd3 100644 --- a/src/routes/printers.ts +++ b/src/routes/printers.ts @@ -8,7 +8,8 @@ import { PrinterInsertSchema } from "../db/validators"; import { printerProjection } from "../db/projections"; import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../auth"; import { PaginationQuery, paginate } from "./pagination"; -import { isGatewayOnline, sendPrinterCommand } from "./ws/edge"; +import { resolveStream } from "../stream"; +import { getStreamBaseUrl, isGatewayOnline, sendPrinterCommand } from "./ws/edge"; const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); @@ -198,4 +199,37 @@ export const printerRoutes = new Elysia({ prefix: "/printers" }) }; }, { params: IdParams, detail: { tags: ["printers"] } }, + ) + /** + * Where a browser can play this printer's camera. + * + * 404 rather than 200-with-nulls when either half is missing: a frontend + * handed `{ streamUrl: null }` has to remember to check, and one that + * forgets renders a broken player. The two causes get different messages + * because they are different problems for whoever debugs it (a P1/A1 that + * can never stream, versus a gateway that is merely offline). + * + * LIMITATION, by design for the MVP: `streamUrl` is the gateway's LAN + * address and nothing relays it. A browser on the gateway's network can + * play it; one outside cannot, and this endpoint cannot tell which the + * caller is. A relay is the fix and does not exist yet. + */ + .get( + "/:id/stream", + async ({ user, params }) => { + await requirePrinter(user.id, params.id, "viewer"); + const printer = await db.query.printers.findFirst({ + columns: { farmId: true, cameraStream: true }, + where: eq(printers.id, params.id), + }); + if (!printer) throw new NotFoundError(`Printer ${params.id} not found`); + + const result = resolveStream(printer.cameraStream, getStreamBaseUrl(printer.farmId)); + if (!result.ok) throw new NotFoundError(result.message); + return result.stream; + }, + { + params: IdParams, + detail: { tags: ["printers"], summary: "Where to play this printer's camera (LAN address)" }, + }, ); diff --git a/src/routes/ws/edge.ts b/src/routes/ws/edge.ts index 9461f98..f27f38c 100644 --- a/src/routes/ws/edge.ts +++ b/src/routes/ws/edge.ts @@ -39,6 +39,15 @@ interface EdgeConn { gatewayId: string; sessionId: string; lastSeenAt: number; + /** + * Where this gateway serves camera restreams, from its hello. Lives on the + * connection rather than in a DB column or a farm-keyed map: it is a LAN + * address that is only reachable while this gateway is up, so it should + * vanish with the socket instead of outliving it as a URL nothing answers. + * The alternative, a column on `farms`, would keep serving a stale address + * after the gateway disconnected or moved. Undefined means none advertised. + */ + streamBaseUrl?: string; send: (message: EdgeServerMessage) => void; /** * Gateway printer id -> our `printers.id`. Filled on the first telemetry @@ -217,6 +226,9 @@ async function persistTelemetry(conn: EdgeConn, batch: PrinterTelemetry[]) { remainingMinutes: snapshot.remaining_minutes ?? null, currentJobName: snapshot.job_name ?? null, activeAlerts: snapshot.active_alerts ?? null, + // Cleared like the rest when absent: a printer that lost its stream + // must stop being offered one. + cameraStream: snapshot.camera_stream ?? null, lastSeenAt: new Date(), updatedAt: new Date(), }) @@ -288,6 +300,16 @@ export function isGatewayOnline(farmId: string): boolean { return wsId !== undefined && connections.has(wsId); } +/** + * The stream base URL the farm's currently connected gateway advertised, or + * undefined if no gateway is connected or it advertised none. Deliberately + * does not distinguish those two: callers only need to know it is unusable. + */ +export function getStreamBaseUrl(farmId: string): string | undefined { + const wsId = gatewayByFarm.get(farmId); + return wsId ? connections.get(wsId)?.streamBaseUrl : undefined; +} + export const edgeWsRoute = new Elysia() .use(jwt({ name: "edgeJwt", secret: edgeJwtSecret })) .ws("/ws/edge/v1", { @@ -334,6 +356,8 @@ export const edgeWsRoute = new Elysia() conn.farmId = farmId; conn.gatewayId = message.gateway_id; + // `?? undefined`: absent and explicit null both mean "no streams". + conn.streamBaseUrl = message.stream_base_url ?? undefined; // One gateway per farm: drop whatever was registered before. On a // reconnect that stale socket is already dead, and leaving it diff --git a/src/stream.ts b/src/stream.ts new file mode 100644 index 0000000..1cee64d --- /dev/null +++ b/src/stream.ts @@ -0,0 +1,29 @@ +/** + * Camera stream resolution, on its own with no imports - the same reason as + * `dispatch/job_status.ts`: `../db` opens a connection at import time, and + * the two distinct failure messages here are worth testing without one. + */ + +export const NO_CAMERA_MESSAGE = "this printer reports no camera that can be streamed"; +export const NO_GATEWAY_STREAM_MESSAGE = + "this farm's gateway is not connected or advertises no stream base URL"; + +export type StreamResolution = + | { ok: true; stream: { streamUrl: string; cameraId: string } } + | { ok: false; reason: "no_camera" | "no_stream_base"; message: string }; + +/** + * Both halves or an error naming the missing one. The printer is checked + * first: if it can never stream, saying the gateway is offline would send + * someone off to fix the wrong thing. + */ +export function resolveStream( + cameraStream: string | null | undefined, + streamBaseUrl: string | undefined, +): StreamResolution { + if (!cameraStream) return { ok: false, reason: "no_camera", message: NO_CAMERA_MESSAGE }; + if (!streamBaseUrl) { + return { ok: false, reason: "no_stream_base", message: NO_GATEWAY_STREAM_MESSAGE }; + } + return { ok: true, stream: { streamUrl: streamBaseUrl, cameraId: cameraStream } }; +} diff --git a/tests/stream.test.ts b/tests/stream.test.ts new file mode 100644 index 0000000..ca39564 --- /dev/null +++ b/tests/stream.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { Value } from "@sinclair/typebox/value"; +import { EdgeClientMessageSchema } from "@continuum/ts-types"; +import { NO_CAMERA_MESSAGE, NO_GATEWAY_STREAM_MESSAGE, resolveStream } from "../src/stream"; + +const hello = { type: "hello", gateway_id: "gw-1", farm_id: "f1", token: "t" }; + +describe("hello stream_base_url", () => { + test("is accepted when present", () => { + const msg = { ...hello, stream_base_url: "http://gateway.local:1984" }; + expect(Value.Check(EdgeClientMessageSchema, msg)).toBe(true); + }); + test("is accepted when absent, the normal case", () => { + expect(Value.Check(EdgeClientMessageSchema, hello)).toBe(true); + }); + test("tolerates an explicit null from a serde producer", () => { + expect(Value.Check(EdgeClientMessageSchema, { ...hello, stream_base_url: null })).toBe(true); + }); +}); + +describe("telemetry camera_stream", () => { + const telemetry = (extra: object) => ({ + type: "telemetry", + printers: [{ printer_id: "p1", name: "X1", ...extra }], + }); + // The persistence step itself is `snapshot.camera_stream ?? null` inside + // persistTelemetry, which needs a database; what is checked here is that + // the value survives the validator that guards that path. + test("survives validation with its value intact", () => { + const msg = telemetry({ camera_stream: "p1" }); + expect(Value.Check(EdgeClientMessageSchema, msg)).toBe(true); + expect((msg.printers[0] as { camera_stream?: string }).camera_stream).toBe("p1"); + }); + test("is optional", () => { + expect(Value.Check(EdgeClientMessageSchema, telemetry({}))).toBe(true); + }); +}); + +describe("resolveStream", () => { + test("returns the endpoint's success shape", () => { + expect(resolveStream("p1", "http://gateway.local:1984")).toEqual({ + ok: true, + stream: { streamUrl: "http://gateway.local:1984", cameraId: "p1" }, + }); + }); + + test("the two failures are distinguishable", () => { + const noCamera = resolveStream(null, "http://gateway.local:1984"); + const noBase = resolveStream("p1", undefined); + if (noCamera.ok || noBase.ok) throw new Error("expected both to fail"); + expect(noCamera.reason).toBe("no_camera"); + expect(noBase.reason).toBe("no_stream_base"); + expect(noCamera.message).toBe(NO_CAMERA_MESSAGE); + expect(noBase.message).toBe(NO_GATEWAY_STREAM_MESSAGE); + expect(noCamera.message).not.toBe(noBase.message); + }); + + test("a printer with no camera is blamed first, even if the gateway is also absent", () => { + const both = resolveStream(undefined, undefined); + expect(!both.ok && both.reason).toBe("no_camera"); + }); +});