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"); + }); +});