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.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "printers" ADD COLUMN "camera_stream" text;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+35
-1
@@ -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)" },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user