Speak the gateway's actual protocol on /ws/edge/v1, and persist telemetry

This route and continuum-proxy had drifted into entirely different
protocols. It expected an `auth` handshake the gateway never sends, one
printer per telemetry message where the gateway sends a batch, UUID printer
ids a gateway has no way to know, and camelCase field names against the
gateway's snake_case. It had no way to send a command to a printer at all.
Telemetry was acknowledged and thrown away - the handler said as much.

It now implements schemas/continuum/v1/edge/ via its TypeBox mirror in
@continuum/ts-types, so both ends mirror one written contract instead of
each other's assumptions.

The missing link was identity: a gateway knows its printers as "p1p", this
database knows them as UUIDs, and nothing mapped between them.
printers.gateway_printer_id is that mapping, unique per farm since two
farms may each have a printer called "p1p". An unrecognised id is logged
and skipped rather than inserted - a gateway should not be able to create
printer records by mentioning them, or a typo in someone's printers.toml
becomes a permanent phantom printer.

Telemetry now lands in `printers` as a latest-value snapshot, extending the
pattern status/nozzle_temp_c/bed_temp_c already used: target temperatures,
progress, layers, remaining minutes, job name, active alert count. Also
vendor_state alongside the narrowed status enum, because a state we do not
recognise yet would otherwise be flattened and lost - and an unrecognised
state deliberately leaves status untouched rather than falling back to
"idle", since idle is what the scheduler reads as safe to send work to.

Commands can now go down: sendPrinterCommand() takes our own printer id,
translates it on the way out, and waits for the gateway's result, with a
10s timeout and a registry keyed by farm. Pending commands are failed
immediately when a gateway disconnects rather than making every caller wait
out the timeout for a socket that is already gone. POST /printers/:id/commands
exposes it, validated against the same shared schema the gateway
accepts, so this route cannot offer something no gateway can execute - which
notably means there is still no way to start a print.

tests/edge_protocol.test.ts is the contract test, run against real frames
captured from a live gateway talking to a real P1P and H2C rather than
hand-written examples - hand-written ones agree with whoever wrote them,
which is how this drifted in the first place. The second fixture is the
same capture from before the gateway was taught to omit empty fields, kept
deliberately: the validator must keep accepting explicit nulls.

Not yet applied to a database: this adds columns but no migration, and the
repo has none committed. Run db:generate (or push) before the next deploy.
This commit is contained in:
2026-10-03 10:37:41 +00:00
parent ab093f3136
commit e2c1e0abe2
7 changed files with 666 additions and 58 deletions
+58
View File
@@ -0,0 +1,58 @@
{
"_note": "The same capture from before the gateway was taught to omit empty fields, kept deliberately: it is what a serde-derived producer emits by default, and the validator must keep accepting it. Note p1p's \"job_name\": null - an idle printer with no job - which used to fail validation and take the whole telemetry batch down with it.",
"frames": [
{
"type": "hello",
"gateway_id": "dev-gw",
"farm_id": "dev-farm",
"token": "dev-token"
},
{
"type": "heartbeat",
"seq": 2
},
{
"type": "telemetry",
"printers": [
{
"printer_id": "p1p",
"name": "P1P",
"state": "IDLE",
"percent": 0,
"layer": 0,
"total_layers": 0,
"remaining_minutes": 0,
"job_name": null,
"nozzle_temp": 23.40625,
"nozzle_target": 0.0,
"bed_temp": 22.4375,
"bed_target": 0.0,
"print_error": 0,
"active_alerts": 0
},
{
"printer_id": "h2c",
"name": "H2C",
"state": "RUNNING",
"percent": 97,
"layer": 50,
"total_layers": 50,
"remaining_minutes": 3,
"job_name": "świeczka-24.stl",
"nozzle_temp": 230.0,
"nozzle_target": 230.0,
"bed_temp": 35.0,
"bed_target": 35.0,
"print_error": 0,
"active_alerts": 3
}
]
},
{
"type": "command_result",
"command_id": "cap-1",
"ok": true,
"error": null
}
]
}