Compare commits

..
2 Commits
Author SHA1 Message Date
octoturge 1f9fd08b40 Add a gated script for the first real print
scripts/real-print-p1p.sh queues a job in a throwaway one-printer farm and
watches the machine print it. Refuses without I_AUTHORISE_A_REAL_PRINT=yes.
2026-10-29 16:20:04 +00:00
octoturge a190c896da Relay P1/A1 camera frames to viewers
GET /printers/:id/snapshot asks the farm's gateway for one JPEG frame
(camera_snapshot_request) and returns it no-store. Concurrent viewers
share one read and a frame under 800 ms old is reused, because the
printer's camera serves one client. /stream answers kind "snapshot" for
these printers; printers.camera_snapshot is set from telemetry.

scripts/e2e-camera-snapshot.sh reads real frames from the P1P through
the real proxy and backend.
2026-10-29 16:11:10 +00:00
11 changed files with 1316 additions and 6 deletions
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "printers" ADD COLUMN "camera_snapshot" boolean DEFAULT false NOT NULL;
+848
View File
@@ -0,0 +1,848 @@
{
"id": "aef1c8fd-6a0a-48b5-9957-26b3630990f3",
"prevId": "ee6e6f93-7aec-431b-955d-75c485352ea7",
"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
},
"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
},
"camera_snapshot": {
"name": "camera_snapshot",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": 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": {}
},
"public.printer_alerts": {
"name": "printer_alerts",
"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
},
"kind": {
"name": "kind",
"type": "alert_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": false
},
"object_key": {
"name": "object_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"acknowledged_by": {
"name": "acknowledged_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"acknowledged_at": {
"name": "acknowledged_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()"
}
},
"indexes": {
"printer_alerts_farm_open_idx": {
"name": "printer_alerts_farm_open_idx",
"columns": [
{
"expression": "farm_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "acknowledged_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"printer_alerts_farm_id_farms_id_fk": {
"name": "printer_alerts_farm_id_farms_id_fk",
"tableFrom": "printer_alerts",
"tableTo": "farms",
"columnsFrom": [
"farm_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"printer_alerts_printer_id_printers_id_fk": {
"name": "printer_alerts_printer_id_printers_id_fk",
"tableFrom": "printer_alerts",
"tableTo": "printers",
"columnsFrom": [
"printer_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"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"
]
},
"public.alert_kind": {
"name": "alert_kind",
"schema": "public",
"values": [
"print_failed",
"print_paused",
"printer_error",
"ai_detection"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -36,6 +36,13 @@
"when": 1793285676878,
"tag": "0004_printer_alerts",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1793289367708,
"tag": "0005_bouncy_cannonball",
"breakpoints": true
}
]
}
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# A P1/A1 camera, end to end: the real proxy reads a JPEG frame from the real
# printer's port 6000 (read-only - it only watches), the real backend relays
# it, and an authenticated viewer fetches it.
#
# Needs a printer in ../continuum-proxy/printers.toml whose camera is the
# P1/A1 kind (CAMERA_ID, default p1p) and that is switched on. Touches the
# printer's camera and MQTT status, nothing else; no command is sent.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
CAMERA_ID="${CAMERA_ID:-p1p}"
GO2RTC_BIN="${GO2RTC_BIN:-$HOME/.local/bin/go2rtc}"
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
PORT="${PORT:-3050}"
AUTH_PORT="${AUTH_PORT:-3051}"
BASE="http://localhost:$PORT"
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}"
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}"
R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}"
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
# Where to keep the last frame for a human to look at. Unset: not kept.
SAVE_FRAME="${SAVE_FRAME:-}"
WORK="$(mktemp -d)"
BACKEND_PID=""; PROXY_PID=""; AUTH_PID=""
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
USER_ID="$(cat /proc/sys/kernel/random/uuid)"
cleanup() {
local rc=$?
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done
for _ in $(seq 1 20); do [ -n "$PROXY_PID" ] && kill -0 "$PROXY_PID" 2>/dev/null || break; sleep 0.3; done
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done
[ -n "${ORG_ID:-}" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
if [ $rc -ne 0 ]; then
echo; echo "--- backend log (tail) ---"; tail -20 "$WORK/backend.log" 2>/dev/null || true
echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true
fi
rm -rf "$WORK"
sleep 0.5
if ps -eo comm | grep -qx go2rtc; then
echo "WARNING: a go2rtc process is still running after cleanup" >&2; [ $rc -eq 0 ] && rc=1
fi
exit $rc
}
trap cleanup EXIT
echo "==> checking preconditions"
psql_q "SELECT camera_snapshot FROM printers LIMIT 0" >/dev/null 2>&1 \
|| fail "database lacks printers.camera_snapshot - run scripts/dev-env.sh schema"
[ -x "$GO2RTC_BIN" ] || fail "no go2rtc at $GO2RTC_BIN"
[ -f "$PROXY_DIR/printers.toml" ] || fail "no $PROXY_DIR/printers.toml"
grep -q "^id *= *\"$CAMERA_ID\"" "$PROXY_DIR/printers.toml" || fail "printers.toml has no printer '$CAMERA_ID'"
ps -eo comm | grep -qx go2rtc && fail "a go2rtc is already running"
CAM_HOST="$(awk -v id="$CAMERA_ID" -F'"' '/^id *=/ {cur=$2} cur==id && /^(host|address|ip) *=/ {print $2; exit}' "$PROXY_DIR/printers.toml")"
if [ -n "$CAM_HOST" ]; then
timeout 4 bash -c "</dev/tcp/$CAM_HOST/6000" 2>/dev/null || fail "$CAMERA_ID ($CAM_HOST) refuses TCP 6000: no snapshot camera, or it is off"
ok "$CAMERA_ID accepts connections on 6000"
fi
echo "==> seeding an organisation and a farm claiming '$CAMERA_ID'"
EDGE_PROXY_TOKEN="e2e-$(date +%s)"
ORG_ID="$(psql_q "INSERT INTO organisations (name) VALUES ('e2e snapshot org') RETURNING id")"
psql_q "INSERT INTO org_members (org_id, user_id, role) VALUES ('$ORG_ID', '$USER_ID', 'viewer')" >/dev/null
FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token) VALUES ('e2e snapshot farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")"
psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id) VALUES ('$FARM_ID', 'snapshot under test', 'bambu', '$CAMERA_ID')" >/dev/null
CAM_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID'")"
echo "==> starting the dev-auth issuer and the backend"
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
AUTH_PID=$!
AUTH_URL="http://localhost:$AUTH_PORT"
for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done
export SUPABASE_URL="$AUTH_URL"
USER_TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')"
DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 &
BACKEND_PID=$!
for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && break; sleep 0.5; done
curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy"
echo "==> starting the real continuum-proxy"
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
env -C "$PROXY_DIR" \
CONTINUUM_GATEWAY_ID=e2e-snap-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_PRINTERS_PATH=printers.toml CONTINUUM_STORE_PATH="$WORK/continuum.db" RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$!
echo "==> waiting for the camera to be advertised as a snapshot camera"
STREAM=""
for _ in $(seq 1 45); do
STREAM="$(curl -sf -H "authorization: Bearer $USER_TOKEN" "$BASE/printers/$CAM_UUID/stream" 2>/dev/null || true)"
[ -n "$STREAM" ] && break
sleep 1
done
[ -n "$STREAM" ] || fail "GET /printers/$CAM_UUID/stream never answered 200"
printf '%s' "$STREAM" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["kind"]=="snapshot" and "iceServers" not in d, d' \
|| fail "the stream answer is not a snapshot answer"
ok "the stream endpoint says kind=snapshot (no WebRTC session offered)"
echo "==> fetching frames"
check_frame() {
local file="$1" label="$2" size
size="$(stat -c %s "$file")"
[ "$size" -gt 1000 ] || fail "$label: only $size bytes"
[ "$(head -c2 "$file" | od -An -tx1 | tr -d ' \n')" = "ffd8" ] || fail "$label: does not start with a JPEG marker"
[ "$(tail -c2 "$file" | od -An -tx1 | tr -d ' \n')" = "ffd9" ] || fail "$label: does not end with a JPEG marker"
ok "$label: a $size-byte JPEG"
}
fetch() { curl -s -o "$1" -D "$1.h" -w '%{http_code}' -H "authorization: Bearer $USER_TOKEN" "$BASE/printers/$CAM_UUID/snapshot"; }
code="$(fetch "$WORK/f1.jpg")"
[ "$code" = 200 ] || fail "first frame: HTTP $code: $(head -c 300 "$WORK/f1.jpg")"
grep -qi '^content-type: image/jpeg' "$WORK/f1.jpg.h" || fail "wrong content-type"
grep -qi '^cache-control: no-store' "$WORK/f1.jpg.h" || fail "frames must be no-store"
check_frame "$WORK/f1.jpg" "first frame"
sleep 1.2
code="$(fetch "$WORK/f2.jpg")"; [ "$code" = 200 ] || fail "second frame: HTTP $code"
check_frame "$WORK/f2.jpg" "second frame (a fresh read after the cache window)"
cmp -s "$WORK/f1.jpg" "$WORK/f2.jpg" && fail "two frames a second apart are byte-identical - is the cache serving stale pictures?"
ok "consecutive frames differ"
echo "==> five viewers at once share the camera"
viewers=()
for i in 1 2 3 4 5; do fetch "$WORK/c$i.jpg" > "$WORK/c$i.code" & viewers+=($!); done
# Not a bare `wait`: that would also wait for the backend and the proxy.
for pid in "${viewers[@]}"; do wait "$pid"; done
for i in 1 2 3 4 5; do [ "$(cat "$WORK/c$i.code")" = 200 ] || fail "viewer $i: HTTP $(cat "$WORK/c$i.code")"; check_frame "$WORK/c$i.jpg" "viewer $i"; done
ok "all five got a picture"
echo "==> access control"
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/printers/$CAM_UUID/snapshot")" = 401 ] || fail "no token must be 401"
ok "no token: 401"
# The code is never read out of printers.toml here; the camera login happens
# on the gateway, and the only thing that should cross is the picture.
if grep -qi 'access_code\|accessCode' "$WORK/backend.log"; then
fail "the backend log mentions an access code"
fi
ok "the backend log never mentions an access code"
[ -n "$SAVE_FRAME" ] && cp "$WORK/f2.jpg" "$SAVE_FRAME" && ok "kept a frame at $SAVE_FRAME"
echo
echo "PASS - a P1/A1 camera frame travelled printer -> proxy -> backend -> an authenticated viewer."
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# THE FIRST REAL DISPATCH: queue a job in a throwaway farm that holds only the
# P1P, let the real scheduler send it through the real proxy, and watch the
# machine print it.
#
# **This starts a physical print.** It refuses to run without
# I_AUTHORISE_A_REAL_PRINT=yes, and only ever targets PRINTER_ID's printer
# (default p1p) because the throwaway farm contains nothing else. The file is
# read from the printer's own SD card root (FILE, default
# auto-test-001.gcode.3mf), uploaded to the object store, and dispatched like
# any customer job; the proxy then downloads it, FTPS-uploads it to the card
# and starts it, mapping filament from the archive itself.
#
# Nothing here cancels the print. To stop it: the printer's own screen, or
# the console's Stop control.
set -euo pipefail
[ "${I_AUTHORISE_A_REAL_PRINT:-}" = "yes" ] || { echo "refusing: set I_AUTHORISE_A_REAL_PRINT=yes to start a real print" >&2; exit 2; }
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
PRINTER_ID="${PRINTER_ID:-p1p}"
FILE="${FILE:-auto-test-001.gcode.3mf}"
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
GO2RTC_BIN="${GO2RTC_BIN:-$HOME/.local/bin/go2rtc}"
PORT="${PORT:-3060}"; AUTH_PORT="${AUTH_PORT:-3061}"
BASE="http://localhost:$PORT"
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
MAX_WAIT_S="${MAX_WAIT_S:-5400}"
WORK="$(mktemp -d)"
BACKEND_PID=""; PROXY_PID=""; AUTH_PID=""; OBJECT_KEY=""
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo " ok: $*"; }
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
USER_ID="$(cat /proc/sys/kernel/random/uuid)"
cleanup() {
local rc=$?
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done
for _ in $(seq 1 20); do [ -n "$PROXY_PID" ] && kill -0 "$PROXY_PID" 2>/dev/null || break; sleep 0.3; done
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done
[ -n "$OBJECT_KEY" ] && bun -e "
import { s3 } from './src/s3';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
await s3.send(new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET ?? 'continuum', Key: process.argv[1] }));
" "$OBJECT_KEY" >/dev/null 2>&1 || true
[ -z "${KEEP_DATA:-}" ] && [ -n "${ORG_ID:-}" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
if [ $rc -ne 0 ]; then
echo "--- backend log (tail) ---"; tail -25 "$WORK/backend.log" 2>/dev/null || true
echo "--- proxy log (tail) ---"; tail -40 "$WORK/proxy.log" 2>/dev/null || true
fi
rm -rf "$WORK"
exit $rc
}
trap cleanup EXIT
[ -f "$PROXY_DIR/printers.toml" ] || fail "no printers.toml"
grep -q "^id *= *\"$PRINTER_ID\"" "$PROXY_DIR/printers.toml" || fail "printers.toml has no printer '$PRINTER_ID'"
ps -eo comm | grep -qx go2rtc && fail "a go2rtc is already running"
echo "==> reading $FILE from $PRINTER_ID's SD card (read-only)"
( cd "$PROXY_DIR" && cargo run -q --example bambu_mapping_check -- "$PRINTER_ID" "$FILE" "$WORK/job.3mf" ) > "$WORK/mapping.txt" 2>&1 || { cat "$WORK/mapping.txt"; fail "could not read the file"; }
grep -E 'gcode_state|project filament|mapping' "$WORK/mapping.txt" | sed 's/^/ /'
grep -q 'gcode_state: Some("IDLE")' "$WORK/mapping.txt" || grep -q 'gcode_state: Some("FINISH")' "$WORK/mapping.txt" || fail "the printer is not idle"
grep -q 'mapping the production path would use' "$WORK/mapping.txt" || fail "the filament would not map; not starting"
WANT="$(sha256sum "$WORK/job.3mf" | cut -d' ' -f1)"
echo "==> seeding a throwaway farm holding only $PRINTER_ID"
EDGE_PROXY_TOKEN="real-$(date +%s)"
ORG_ID="$(psql_q "INSERT INTO organisations (name) VALUES ('real print org') RETURNING id")"
psql_q "INSERT INTO org_members (org_id, user_id, role) VALUES ('$ORG_ID', '$USER_ID', 'owner')" >/dev/null
FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token) VALUES ('real print farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")"
PRN_NAME="$(awk -F'"' -v id="$PRINTER_ID" '/^id *=/ {cur=$2} cur==id && /^name *=/ {print $2; exit}' "$PROXY_DIR/printers.toml")"
psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id) VALUES ('$FARM_ID', '${PRN_NAME:-P1P}', 'bambu', '$PRINTER_ID')" >/dev/null
PRN_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID'")"
[ "$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_ID'")" = 1 ] || fail "the farm must hold exactly one printer"
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
AUTH_PID=$!
AUTH_URL="http://localhost:$AUTH_PORT"
for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done
export SUPABASE_URL="$AUTH_URL"
TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')"
echo "==> starting the backend WITH dispatch on"
DISPATCH_ENABLED=true bun run src/index.ts > "$WORK/backend.log" 2>&1 &
BACKEND_PID=$!
for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && break; sleep 0.5; done
curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy"
echo "==> starting the real proxy"
env -C "$PROXY_DIR" \
CONTINUUM_GATEWAY_ID=real-print-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_PRINTERS_PATH=printers.toml CONTINUUM_STORE_PATH="$WORK/continuum.db" RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$!
for _ in $(seq 1 60); do
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = "idle" ] && break
sleep 1
done
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = "idle" ] || fail "the cloud never saw $PRINTER_ID idle"
ok "the cloud sees $PRINTER_ID idle"
echo "==> queueing the job"
J="$(curl -sf -X POST "$BASE/jobs" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d "{\"farmId\":\"$FARM_ID\",\"fileName\":\"$FILE\",\"contentType\":\"application/octet-stream\"}")"
JOB_ID="$(printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["job"]["id"])')"
OBJECT_KEY="$(printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["job"]["r2ObjectKey"])')"
UPLOAD_URL="$(printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["uploadUrl"])')"
# The job is queued the moment it exists; dispatch needs the bytes, so the
# window between the two is a race the scheduler can only lose (the proxy's
# download then fails and the job fails), which is why this is done at once.
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/octet-stream' --data-binary @"$WORK/job.3mf" "$UPLOAD_URL")"
[ "$CODE" = 200 ] || fail "upload returned HTTP $CODE"
ok "job $JOB_ID queued, $(stat -c %s "$WORK/job.3mf") bytes uploaded"
echo "==> watching (up to ${MAX_WAIT_S}s)"
SEEN=""; LAST=""; START=$SECONDS
while [ $((SECONDS - START)) -lt "$MAX_WAIT_S" ]; do
STATUS="$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")"
PRN="$(psql_q "SELECT coalesce(vendor_state,'-') || ' ' || coalesce(progress_percent::text,'-') || '% layer ' || coalesce(current_layer::text,'-') || '/' || coalesce(total_layers::text,'-') || ' nozzle ' || coalesce(nozzle_temp_c::text,'-') FROM printers WHERE id='$PRN_UUID'")"
NOW="job=$STATUS printer=$PRN"
[ "$NOW" != "$LAST" ] && { echo " [$((SECONDS - START))s] $NOW"; LAST="$NOW"; }
case " $SEEN " in *" $STATUS "*) ;; *) SEEN="$SEEN $STATUS" ;; esac
[ "$STATUS" = completed ] && break
[ "$STATUS" = failed ] && break
sleep 5
done
echo
echo "job status history:$SEEN"
if [ "$STATUS" = completed ]; then
grep -E 'uploaded a project file|mapped the project|start' "$WORK/proxy.log" | sed 's/^/ proxy: /' | head -8
echo "PASS - the first real dispatch: queued in the cloud, scheduled, uploaded to the P1P, printed and completed."
else
fail "the job ended as '$STATUS'"
fi
+1
View File
@@ -81,6 +81,7 @@ export const printerProjection = projection(printers, [
// `GET /printers/:id/stream` per printer, and in a mixed fleet most of
// those 404 by design.
"cameraStream",
"cameraSnapshot",
"vendorState",
"vendorErrorCode",
"lastSeenAt",
+6 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real, integer, uniqueIndex } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real, integer, boolean, uniqueIndex } from "drizzle-orm/pg-core";
import { farms } from "./farms";
export const printerVendorEnum = pgEnum("printer_vendor", ["bambu", "prusa", "klipper"]);
@@ -70,6 +70,11 @@ export const printers = pgTable(
* restream it.
*/
cameraStream: text("camera_stream"),
/**
* The camera can only be read as single JPEG frames (a P1/A1), which the
* gateway serves on request. Mutually exclusive with `cameraStream`.
*/
cameraSnapshot: boolean("camera_snapshot").notNull().default(false),
/**
* The printer's own state string, kept alongside the narrowed
* `status` enum. Worth storing both: the enum is what the UI filters
+41 -4
View File
@@ -8,9 +8,9 @@ import { PrinterCreateSchema, PrinterUpdateSchema } from "../db/validators";
import { printerProjection } from "../db/projections";
import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../auth";
import { PaginationQuery, paginate } from "./pagination";
import { resolveStream } from "../stream";
import { resolveSnapshot, resolveStream } from "../stream";
import { mintIceServers, turnConfigured } from "../camera/ice";
import { isGatewayOnline, negotiateCameraStream, requestGateway, sendPrinterCommand } from "./ws/edge";
import { isGatewayOnline, negotiateCameraStream, requestCameraSnapshot, requestGateway, sendPrinterCommand } from "./ws/edge";
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
@@ -227,11 +227,22 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
async ({ user, params }) => {
await requirePrinter(user.id, params.id, "viewer");
const printer = await db.query.printers.findFirst({
columns: { farmId: true, cameraStream: true },
columns: { farmId: true, cameraStream: true, cameraSnapshot: true },
where: eq(printers.id, params.id),
});
if (!printer) throw new NotFoundError(`Printer ${params.id} not found`);
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
if (!printer.cameraStream && printer.cameraSnapshot) {
const snap = resolveSnapshot(true, isGatewayOnline(printer.farmId));
if (!snap.ok) throw new NotFoundError(snap.message);
return {
kind: "snapshot" as const,
snapshotPath: `/printers/${params.id}/snapshot`,
intervalMs: 1000,
};
}
// The gateway has to be connected, because it is what answers the
// offer - a camera name alone would start a negotiation with nobody.
const result = resolveStream(printer.cameraStream, isGatewayOnline(printer.farmId));
@@ -241,7 +252,7 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
// credential is useful is small. Never cached in the client beyond the
// session it opens.
const iceServers = await mintIceServers();
return { ...result.stream, iceServers: iceServers ?? [], turnConfigured: turnConfigured() };
return { kind: "webrtc" as const, ...result.stream, iceServers: iceServers ?? [], turnConfigured: turnConfigured() };
},
{
params: IdParams,
@@ -251,6 +262,32 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
},
},
)
/**
* One JPEG frame from a printer whose camera is not RTSP (Bambu P1/A1).
*
* Viewer-level like the stream. The picture crosses the cloud, which the
* WebRTC path avoids; see `requestCameraSnapshot` for why that is bounded.
* `no-store` because each fetch must be the next frame, not a cached one.
*/
.get(
"/:id/snapshot",
async ({ user, params, set, status }) => {
await requirePrinter(user.id, params.id, "viewer");
let result: { jpeg?: Buffer; error?: string };
try {
result = await requestCameraSnapshot(params.id);
} catch (err) {
return status(404, { error: "NO_SNAPSHOT_CAMERA", message: err instanceof Error ? err.message : String(err) });
}
if (!result.jpeg) {
return status(503, { error: "CAMERA_UNAVAILABLE", message: result.error ?? "no picture" });
}
set.headers["content-type"] = "image/jpeg";
set.headers["cache-control"] = "no-store";
return new Response(result.jpeg, { headers: { "content-type": "image/jpeg", "cache-control": "no-store" } });
},
{ params: IdParams, detail: { tags: ["printers"], summary: "One camera frame (P1/A1)" } },
)
/**
* Relays a browser's WebRTC offer to the printer's gateway and returns
* go2rtc's answer.
+83
View File
@@ -75,6 +75,11 @@ interface PendingWebrtc {
timer: ReturnType<typeof setTimeout>;
}
interface PendingSnapshot {
resolve: (result: { jpeg?: Buffer; error?: string }) => void;
timer: ReturnType<typeof setTimeout>;
}
interface EdgeConn {
farmId: string;
gatewayId: string;
@@ -124,6 +129,8 @@ interface EdgeConn {
* retried.
*/
webrtc: Map<string, PendingWebrtc>;
/** In-flight camera frame reads, keyed by request id. */
snapshots: Map<string, PendingSnapshot>;
}
const connections = new Map<string, EdgeConn>();
@@ -414,6 +421,7 @@ async function persistTelemetry(conn: EdgeConn, batch: PrinterTelemetry[]) {
// Cleared like the rest when absent: a printer that lost its stream
// must stop being offered one.
cameraStream: snapshot.camera_stream ?? null,
cameraSnapshot: snapshot.camera_snapshot ?? false,
lastSeenAt: new Date(),
updatedAt: new Date(),
})
@@ -615,6 +623,62 @@ export async function negotiateCameraStream(
});
}
/** A frame this fresh is served again instead of asking the printer. */
const SNAPSHOT_MAX_AGE_MS = 800;
/** Connect, TLS and a frame from the printer take a few seconds. */
const SNAPSHOT_TIMEOUT_MS = 15_000;
const snapshotInFlight = new Map<string, Promise<{ jpeg?: Buffer; error?: string }>>();
const snapshotCache = new Map<string, { at: number; jpeg: Buffer }>();
/**
* One JPEG frame from a P1/A1-style camera, read by the printer's gateway.
*
* Unlike the WebRTC path the picture itself crosses the cloud - there is no
* codec a browser could be given instead - so this is deliberately small:
* one frame per request, concurrent viewers of a printer share one read, and
* a frame under a second old is served again. The printer's camera is a
* single-client port, and ten browsers must not become ten logins.
*
* Throws for an unknown printer, one with no snapshot camera, or a farm with
* no gateway connected; resolves `{ error }` for a read that failed.
*/
export async function requestCameraSnapshot(printerId: string): Promise<{ jpeg?: Buffer; error?: string }> {
const printer = await db.query.printers.findFirst({
columns: { farmId: true, gatewayPrinterId: true, cameraSnapshot: true },
where: eq(printers.id, printerId),
});
if (!printer) throw new Error(`printer ${printerId} not found`);
if (!printer.cameraSnapshot) throw new Error(`printer ${printerId} has no snapshot camera`);
if (!printer.gatewayPrinterId) throw new Error(`printer ${printerId} is not claimed by a gateway`);
const cached = snapshotCache.get(printerId);
if (cached && Date.now() - cached.at < SNAPSHOT_MAX_AGE_MS) return { jpeg: cached.jpeg };
const running = snapshotInFlight.get(printerId);
if (running) return running;
const wsId = gatewayByFarm.get(printer.farmId);
const conn = wsId ? connections.get(wsId) : undefined;
if (!conn) throw new Error(`no gateway connected for farm ${printer.farmId}`);
const requestId = crypto.randomUUID();
const gatewayPrinterId = printer.gatewayPrinterId;
const promise = new Promise<{ jpeg?: Buffer; error?: string }>((resolve) => {
const timer = setTimeout(() => {
conn.snapshots.delete(requestId);
resolve({ error: `gateway did not answer within ${SNAPSHOT_TIMEOUT_MS}ms` });
}, SNAPSHOT_TIMEOUT_MS);
conn.snapshots.set(requestId, { resolve, timer });
conn.send({ type: "camera_snapshot_request", request_id: requestId, printer_id: gatewayPrinterId });
}).then((result) => {
if (result.jpeg) snapshotCache.set(printerId, { at: Date.now(), jpeg: result.jpeg });
snapshotInFlight.delete(printerId);
return result;
});
snapshotInFlight.set(printerId, promise);
return promise;
}
/** Whether a farm currently has a gateway connected. */
export function isGatewayOnline(farmId: string): boolean {
const wsId = gatewayByFarm.get(farmId);
@@ -681,6 +745,7 @@ export const edgeWsRoute = new Elysia()
lastStatus: new Map(),
pending: new Map(),
webrtc: new Map(),
snapshots: new Map(),
};
conn.handshakeTimer = setTimeout(() => {
@@ -776,6 +841,19 @@ export const edgeWsRoute = new Elysia()
break;
}
case "camera_snapshot": {
const waiting = conn.snapshots.get(message.request_id);
if (!waiting) return; // the viewer's request already timed out
clearTimeout(waiting.timer);
conn.snapshots.delete(message.request_id);
waiting.resolve(
message.jpeg_base64
? { jpeg: Buffer.from(message.jpeg_base64, "base64") }
: { error: message.error ?? "the gateway returned no picture" },
);
break;
}
case "command_result": {
const pending = conn.pending.get(message.command_id);
if (!pending) {
@@ -885,6 +963,11 @@ export const edgeWsRoute = new Elysia()
waiting.resolve({ error: "gateway disconnected before answering" });
conn.webrtc.delete(sessionId);
}
for (const [requestId, waiting] of conn.snapshots) {
clearTimeout(waiting.timer);
waiting.resolve({ error: "gateway disconnected before answering" });
conn.snapshots.delete(requestId);
}
if (conn.farmId && gatewayByFarm.get(conn.farmId) === ws.id) {
gatewayByFarm.delete(conn.farmId);
}
+15
View File
@@ -35,3 +35,18 @@ export function resolveStream(
}
return { ok: true, stream: { cameraId: cameraStream } };
}
export const NO_SNAPSHOT_MESSAGE = "this printer's camera cannot be read as pictures";
/**
* For a P1/A1-style camera: no stream to negotiate, a picture to fetch
* repeatedly. Same ordering rule as `resolveStream` - the printer first.
*/
export function resolveSnapshot(
cameraSnapshot: boolean | null | undefined,
gatewayOnline: boolean,
): { ok: true } | { ok: false; reason: "no_camera" | "gateway_offline"; message: string } {
if (!cameraSnapshot) return { ok: false, reason: "no_camera", message: NO_SNAPSHOT_MESSAGE };
if (!gatewayOnline) return { ok: false, reason: "gateway_offline", message: NO_GATEWAY_MESSAGE };
return { ok: true };
}
+21 -1
View File
@@ -1,7 +1,7 @@
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_MESSAGE, resolveStream } from "../src/stream";
import { NO_CAMERA_MESSAGE, NO_GATEWAY_MESSAGE, NO_SNAPSHOT_MESSAGE, resolveSnapshot, resolveStream } from "../src/stream";
const hello = { type: "hello", gateway_id: "gw-1", farm_id: "f1", token: "t" };
@@ -63,3 +63,23 @@ describe("resolveStream", () => {
expect(!both.ok && both.reason).toBe("no_camera");
});
});
describe("snapshot cameras (P1/A1)", () => {
test("telemetry and the gateway's answer survive the validators", () => {
expect(
Value.Check(EdgeClientMessageSchema, {
type: "telemetry",
printers: [{ printer_id: "p1", name: "P1P", camera_snapshot: true }],
}),
).toBe(true);
expect(Value.Check(EdgeClientMessageSchema, { type: "camera_snapshot", request_id: "r", jpeg_base64: "/9j/" })).toBe(true);
expect(Value.Check(EdgeClientMessageSchema, { type: "camera_snapshot", request_id: "r", error: "no frame" })).toBe(true);
expect(Value.Check(EdgeClientMessageSchema, { type: "camera_snapshot", jpeg_base64: "/9j/" })).toBe(false);
});
test("the printer is checked before the gateway", () => {
expect(resolveSnapshot(false, false)).toMatchObject({ ok: false, reason: "no_camera", message: NO_SNAPSHOT_MESSAGE });
expect(resolveSnapshot(true, false)).toMatchObject({ ok: false, reason: "gateway_offline", message: NO_GATEWAY_MESSAGE });
expect(resolveSnapshot(true, true)).toEqual({ ok: true });
});
});