A gateway can find printers on its own network and USB ports, but nothing carried that back to the cloud, so adding one in the web app still meant an operator reading an IP address and a serial number off a machine by hand. Two schemas, both gateway -> cloud: - `DiscoveredPrinter`, a candidate the gateway found but cannot drive, because it is not in that gateway's fleet file. - A `discovered_printers` variant on the client message, carrying a whole batch that replaces what that gateway last reported. A printer that has gone is reported by its absence, and a gateway seeing nothing sends an empty list. A candidate deliberately carries no credential, and cannot: a Bambu access code is on the printer's screen and a PrusaLink API key is in its web UI, so no probe learns either. What this buys is the address and the identity, which are the parts an operator would otherwise have to go and look up. Two details worth keeping in mind downstream: `ambiguous_with` exists because PrusaLink serves OctoPrint's API, so a probe that saw only that API cannot honestly claim PrusaLink. The candidate says `octoprint` and names the alternative, and the operator settles it. Every string here was chosen by a device on the customer's network - anything answering an SSDP query or an HTTP probe names itself - so they are untrusted display strings. Hence the length caps, and hence `DiscoveredPrinter` being closed where its sibling variants are open: an unbounded LAN-supplied `name` should not reach a database column or a React tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
8.3 KiB
JSON
214 lines
8.3 KiB
JSON
{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/edge/client_message.schema.json",
|
|
"title": "EdgeClientMessage",
|
|
"description": "What a continuum-proxy gateway sends up to continuum-backend's /ws/edge/v1 route. Discriminated on 'type'. Field names are snake_case, matching every other schema in this repository - note that this differs from the camelCase the backend's HTTP routes use, because this wire format is shared with a Rust consumer whose serde structs derive these names directly.",
|
|
"oneOf": [
|
|
{
|
|
"type": "object",
|
|
"title": "Hello",
|
|
"description": "First message on every connection, before anything else is accepted. Carries NO credential: the edge JWT minted by POST /auth/edge-token goes in an Authorization: Bearer header on the WebSocket upgrade request, and is verified before the socket exists, so an unauthenticated peer gets an HTTP 401 and no socket at all. This message used to carry a 'token' field; it was removed, because a token in the first frame means a live socket and a parsed message for a peer that has proved nothing. farm_id here is redundant with the token's own claim and is checked against it rather than trusted - a gateway naming a farm its token was not minted for is refused.",
|
|
"properties": {
|
|
"type": {
|
|
"const": "hello"
|
|
},
|
|
"gateway_id": {
|
|
"type": "string"
|
|
},
|
|
"farm_id": {
|
|
"type": "string"
|
|
},
|
|
"stream_base_url": {
|
|
"type": "string",
|
|
"format": "uri",
|
|
"description": "Where this gateway serves camera restreams, e.g. http://gateway.local:1984 - the base of a go2rtc instance the gateway supervises. Optional: a gateway with no cameras configured, or with go2rtc not installed, omits it. Reported by the gateway rather than configured in the cloud because only the gateway knows the address it is actually reachable at. NOTE the consequence, which is a real limit and not a bug: this is a LAN address. A browser on the same network as the gateway can play the stream directly; one outside it cannot, and making that work needs a relay that does not exist yet. Anything surfacing this to a user has to cope with it being unreachable."
|
|
}
|
|
},
|
|
"required": [
|
|
"type",
|
|
"gateway_id",
|
|
"farm_id"
|
|
]
|
|
},
|
|
{
|
|
"type": "object",
|
|
"title": "Heartbeat",
|
|
"properties": {
|
|
"type": {
|
|
"const": "heartbeat"
|
|
},
|
|
"seq": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"description": "Monotonic per connection, restarting at 1 on reconnect. Echoed back in heartbeat_ack so the gateway can tell a stalled connection from a slow one."
|
|
}
|
|
},
|
|
"required": [
|
|
"type",
|
|
"seq"
|
|
]
|
|
},
|
|
{
|
|
"type": "object",
|
|
"title": "Telemetry",
|
|
"description": "One snapshot per printer that has something to report, on a timer. Sent as a batch rather than a message per printer: a farm has one uplink and many printers, and the backend wants a coherent picture of the farm at an instant more than it wants the lowest possible latency per machine.",
|
|
"properties": {
|
|
"type": {
|
|
"const": "telemetry"
|
|
},
|
|
"printers": {
|
|
"type": "array",
|
|
"items": {
|
|
"$ref": "./printer_telemetry.schema.json"
|
|
}
|
|
}
|
|
},
|
|
"required": [
|
|
"type",
|
|
"printers"
|
|
]
|
|
},
|
|
{
|
|
"type": "object",
|
|
"title": "CommandResult",
|
|
"description": "The reply to a server 'command'. Correlated by command_id, which the backend generates - unlike Bambu's sequence_id, Continuum owns both ends of this protocol, so the side that needs the correlation gets to choose it.",
|
|
"properties": {
|
|
"type": {
|
|
"const": "command_result"
|
|
},
|
|
"command_id": {
|
|
"type": "string"
|
|
},
|
|
"ok": {
|
|
"type": "boolean"
|
|
},
|
|
"error": {
|
|
"type": [
|
|
"string",
|
|
"null"
|
|
],
|
|
"description": "Present only when ok is false. A human-readable message, not a code: its only destination is an operator looking at why a command did not take. Readers must accept an explicit null here as equivalent to the key being absent: a gateway omits what it has no value for, but a serde-derived Rust producer emits null unless every field is annotated, and rejecting a whole batch over that is not worth it."
|
|
}
|
|
},
|
|
"required": [
|
|
"type",
|
|
"command_id",
|
|
"ok"
|
|
]
|
|
},
|
|
{
|
|
"type": "object",
|
|
"title": "FrameBatch",
|
|
"description": "Camera stills already uploaded to object storage, handed over for the AI worker to score. Not yet sent by any gateway - continuum-proxy restreams cameras through go2rtc and has no frame-capture path yet - but the backend consumes it, so it is part of the contract.",
|
|
"properties": {
|
|
"type": {
|
|
"const": "frame_batch"
|
|
},
|
|
"printer_id": {
|
|
"type": "string"
|
|
},
|
|
"print_job_id": {
|
|
"type": [
|
|
"string",
|
|
"null"
|
|
],
|
|
"format": "uuid",
|
|
"description": "Readers must accept an explicit null here as equivalent to the key being absent: a gateway omits what it has no value for, but a serde-derived Rust producer emits null unless every field is annotated, and rejecting a whole batch over that is not worth it."
|
|
},
|
|
"frames": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"object_key": {
|
|
"type": "string"
|
|
},
|
|
"captured_at": {
|
|
"type": "string",
|
|
"format": "date-time"
|
|
}
|
|
},
|
|
"required": [
|
|
"object_key",
|
|
"captured_at"
|
|
]
|
|
}
|
|
}
|
|
},
|
|
"required": [
|
|
"type",
|
|
"printer_id",
|
|
"frames"
|
|
]
|
|
},
|
|
{
|
|
"type": "object",
|
|
"title": "WebrtcAnswer",
|
|
"description": "go2rtc's answer SDP for a WebrtcOffer, passed through verbatim.",
|
|
"properties": {
|
|
"type": {
|
|
"const": "webrtc_answer"
|
|
},
|
|
"session_id": {
|
|
"type": "string"
|
|
},
|
|
"sdp": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"required": [
|
|
"type",
|
|
"session_id",
|
|
"sdp"
|
|
]
|
|
},
|
|
{
|
|
"type": "object",
|
|
"title": "WebrtcError",
|
|
"description": "The gateway could not get an answer: no such camera, go2rtc not running, or it refused the offer. A variant of its own rather than an empty answer, so the viewer is told something instead of watching a video element that never loads.",
|
|
"properties": {
|
|
"type": {
|
|
"const": "webrtc_error"
|
|
},
|
|
"session_id": {
|
|
"type": "string"
|
|
},
|
|
"message": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"required": [
|
|
"type",
|
|
"session_id",
|
|
"message"
|
|
]
|
|
},
|
|
{
|
|
"type": "object",
|
|
"title": "DiscoveredPrinters",
|
|
"description": "Printers the gateway found on its own network or USB ports that are not in its fleet file, so an operator can add one in the web app without first reading an IP address or a serial off a machine. Sent as a whole batch and treated as a replacement for what this gateway previously reported, not as additions: a printer that has gone is reported by its absence, and a gateway that can see nothing sends an empty list. Only sent when discovery is enabled in the gateway's own config, which it is NOT by default - probing a customer's network is something they have to ask for.",
|
|
"properties": {
|
|
"type": {
|
|
"const": "discovered_printers"
|
|
},
|
|
"printers": {
|
|
"type": "array",
|
|
"maxItems": 256,
|
|
"items": {
|
|
"$ref": "./discovered_printer.schema.json"
|
|
}
|
|
},
|
|
"scanned_at": {
|
|
"description": "When the gateway finished the scan these results came from, so the cloud can show the age of the list rather than implying it is live.",
|
|
"type": "string",
|
|
"format": "date-time"
|
|
}
|
|
},
|
|
"required": [
|
|
"type",
|
|
"printers"
|
|
]
|
|
}
|
|
]
|
|
}
|