Compare commits
25
Commits
main
..
5cf7621b13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cf7621b13
|
||
|
|
8e36f39609
|
||
|
|
1bfd883a9b
|
||
|
|
9d995e8e03
|
||
|
|
f7efe0a273
|
||
|
|
e49e72a97b
|
||
|
|
2458798880
|
||
|
|
da6281f5d8
|
||
|
|
de5aa86a2b
|
||
|
|
053f1d3ec4
|
||
|
|
d4b9f62de0
|
||
|
|
51dba8e896
|
||
|
|
1b23c58eb6
|
||
|
|
20ecc1e8bd
|
||
|
|
fe735c310b
|
||
|
|
4cc40c913c
|
||
|
|
cd18337e12
|
||
|
|
8d4a08e36d
|
||
|
|
bc4ee8690a
|
||
|
|
99c1d038f9
|
||
|
|
f0bb8714a3
|
||
|
|
7505e759e1
|
||
|
|
e806a13c70
|
||
|
|
34c7c97d4f
|
||
|
|
660855ecd6
|
@@ -10,7 +10,3 @@ Cargo.lock
|
||||
|
||||
# Personal study files, not part of the real schema build.
|
||||
/examples/
|
||||
|
||||
# Emitted by `tsc -b` into each package's dist/ - never commit build output.
|
||||
*.js.map
|
||||
*.d.ts.map
|
||||
|
||||
@@ -13,39 +13,19 @@ JSON Schema, compiled into two consumer packages:
|
||||
## Layout
|
||||
|
||||
```
|
||||
schemas/continuum/v1/ Continuum's own model
|
||||
ai/ continuum-backend <-> continuum-ai-worker: the
|
||||
stream:camera_frames work queue and the
|
||||
channel:print_alerts notification channel
|
||||
edge/ continuum-backend <-> continuum-proxy: the
|
||||
/ws/edge/v1 gateway uplink protocol
|
||||
material/ printer/ domain objects
|
||||
vendor.schema.json
|
||||
schemas/continuum/v1/ Continuum's own domain model (PrintJob, Template, ...)
|
||||
schemas/bambulab/ Bambu Lab MQTT wire schemas (v1/v2, report + request)
|
||||
packages/ts-types/ TypeBox schemas (hand-written, mirrors schemas/)
|
||||
packages/rust-types/ serde structs (hand-written, mirrors schemas/)
|
||||
```
|
||||
|
||||
The two inter-service protocols - `edge/` and `ai/` - are here for a
|
||||
specific reason: each has two independent implementations, one per repo, and
|
||||
when there was nothing written down between them they drifted completely
|
||||
while every test passed. `/ws/edge/v1` had an `auth` handshake the gateway
|
||||
never sent; `stream:camera_frames` had a producer writing `printerId` and a
|
||||
consumer reading `printer_id`, so every frame was attributed to a printer
|
||||
called "unknown". Neither failure was visible from either side alone, which
|
||||
is what a canonical schema plus a contract test on each end is for.
|
||||
|
||||
`schemas/continuum/v1/*.schema.json` is the canonical shape for each
|
||||
domain object; `packages/ts-types/src/schemas/` and
|
||||
`packages/rust-types/src/lib.rs` both mirror it by hand. A mirror is added
|
||||
per schema only once something actually consumes it, so not every file here
|
||||
has one, and the two inter-service protocols keep their Rust mirror in the
|
||||
single repo that consumes it rather than in `packages/rust-types`:
|
||||
`edge/` in `continuum-proxy/src/uplink/protocol.rs`, `ai/` in
|
||||
`continuum-ai-worker/src/task.rs`. The set is small and changes slowly,
|
||||
which is why keeping hand-synced copies in sync is less overhead than a
|
||||
codegen pipeline (`protoc`/`ts-proto`/`prost-build`) nothing actually
|
||||
consumed - see git history for the
|
||||
`packages/rust-types/src/lib.rs` both mirror it by hand. There are only
|
||||
four small, stable DTOs here (`PrintJob`, `MaterialProfile`,
|
||||
`PrintParameters`, `Template`), so keeping two hand-synced copies in sync
|
||||
is less overhead than running a codegen pipeline (`protoc`/`ts-proto`/
|
||||
`prost-build`) nothing actually consumed - see git history for the
|
||||
protobuf version this replaced, and why it was dropped: every service in
|
||||
this system talks JSON (JSON Schema for the wire, TypeBox/serde for
|
||||
runtime validation), so protobuf's actual value - a single schema compiled
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# Licence policy for a closed-source commercial product.
|
||||
# See docs/LICENSES.md in continuum-common for the rationale and the
|
||||
# non-cargo dependencies (external binaries, ML models) this file cannot see.
|
||||
#
|
||||
# Run: cargo deny check licenses
|
||||
|
||||
[licenses]
|
||||
# Permissive only. Anything copyleft reaching a shipped binary would make
|
||||
# the product undistributable without releasing our source.
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Zlib",
|
||||
"Unicode-3.0",
|
||||
"Unlicense",
|
||||
"MPL-2.0",
|
||||
"CC0-1.0",
|
||||
"BlueOak-1.0.0",
|
||||
]
|
||||
confidence-threshold = 0.9
|
||||
|
||||
# Our own crates declare `LicenseRef-Proprietary`, which is deliberately not
|
||||
# an SPDX identifier and must never appear in the allow list above - a
|
||||
# third-party crate claiming it would be a real finding. They are recognised
|
||||
# as first-party by `publish = false` in their manifests.
|
||||
private = { ignore = true }
|
||||
|
||||
[bans]
|
||||
deny = []
|
||||
|
||||
[advisories]
|
||||
yanked = "deny"
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"name": "continuum-schemas",
|
||||
"license": "UNLICENSED",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
name = "continuum-rust-types"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# Proprietary: deliberately not an SPDX id, so cargo-deny treats it as
|
||||
# first-party. See docs/LICENSES.md in continuum-common.
|
||||
license = "LicenseRef-Proprietary"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "continuum_types"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"name": "@continuum/ts-types",
|
||||
"license": "UNLICENSED",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -10,6 +9,6 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.52"
|
||||
"@sinclair/typebox": "^0.32.0"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export * from "./schemas";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./schemas";
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
/**
|
||||
* The `stream:camera_frames` work queue and the `channel:print_alerts`
|
||||
* notification channel, mirroring `schemas/continuum/v1/ai/*.schema.json`
|
||||
* by hand - the JSON Schema files are canonical, and the Rust mirror lives
|
||||
* in `continuum-ai-worker/src/task.rs`.
|
||||
*
|
||||
* These two are the only interface between `continuum-backend` and
|
||||
* `continuum-ai-worker`. They are written down here because the previous
|
||||
* arrangement - a producer and a consumer each naming their own fields,
|
||||
* with nothing in between - produced exactly the drift it sounds like: the
|
||||
* backend wrote `printerId` and the worker read `printer_id`, so every
|
||||
* frame the worker ever handled was attributed to a printer called
|
||||
* "unknown", and nothing failed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One entry on `stream:camera_frames`.
|
||||
*
|
||||
* ## Every value is a string
|
||||
*
|
||||
* A Valkey stream entry is a flat field/value map of strings. There is no
|
||||
* nesting and no numeric type, which is why `FrameTaskSchema` has no
|
||||
* numbers and no nested objects - a producer that needs one has to encode
|
||||
* it, and then both sides have to agree on the encoding, which is the thing
|
||||
* this file exists to avoid.
|
||||
*
|
||||
* ## An absent field is absent
|
||||
*
|
||||
* `print_job_id` is omitted from the XADD when the frame belongs to no
|
||||
* tracked job - not written as `""`. An empty string is a value: a consumer
|
||||
* that passes it along ends up looking up job id `""`, and one that writes
|
||||
* it to Postgres violates a uuid column. So the producer omits the field and
|
||||
* the consumer treats absent and empty alike.
|
||||
*/
|
||||
export const FrameTaskSchema = Type.Object({
|
||||
/** `farms.id`. */
|
||||
farm_id: Type.String({ format: "uuid" }),
|
||||
/**
|
||||
* `printers.id` - the backend's UUID, already resolved from the gateway's
|
||||
* own printer id, which means nothing outside its farm.
|
||||
*/
|
||||
printer_id: Type.String({ format: "uuid" }),
|
||||
/** `print_jobs.id`, or omitted. Never `""`. */
|
||||
print_job_id: Type.Optional(Type.String({ format: "uuid" })),
|
||||
/** Where the frame is in R2. The bytes are never in the stream. */
|
||||
r2_object_key: Type.String({ minLength: 1 }),
|
||||
/**
|
||||
* The gateway's clock, at capture - not enqueue time. A gateway that
|
||||
* buffered frames through an uplink outage produces tasks whose
|
||||
* `captured_at` is minutes old.
|
||||
*/
|
||||
captured_at: Type.String({ format: "date-time" }),
|
||||
});
|
||||
export type FrameTask = Static<typeof FrameTaskSchema>;
|
||||
|
||||
/**
|
||||
* One message on `channel:print_alerts`, as JSON.
|
||||
*
|
||||
* Unlike a stream entry this one is a document, because PUBLISH carries a
|
||||
* single opaque string. It is also fire-and-forget: an alert published with
|
||||
* no subscriber is gone, so this channel is a live-console feed and not the
|
||||
* record of a detection.
|
||||
*/
|
||||
export const PrintAlertSchema = Type.Object({
|
||||
printer_id: Type.String({ format: "uuid" }),
|
||||
print_job_id: Type.Optional(Type.String({ format: "uuid" })),
|
||||
/**
|
||||
* Free string, not an enum: the label set belongs to the model file and
|
||||
* changes without this schema changing.
|
||||
*/
|
||||
label: Type.String(),
|
||||
/**
|
||||
* The model's own score. Thresholding is the consumer's policy - an
|
||||
* operator may well want to see near-misses.
|
||||
*/
|
||||
confidence: Type.Number({ minimum: 0, maximum: 1 }),
|
||||
/** From the frame, so a console shows when the printer looked like this. */
|
||||
captured_at: Type.String({ format: "date-time" }),
|
||||
/** So a console can show the picture, not just the label. */
|
||||
r2_object_key: Type.String(),
|
||||
});
|
||||
export type PrintAlert = Static<typeof PrintAlertSchema>;
|
||||
|
||||
/** The stream the tasks go on, and the group the worker reads as. */
|
||||
export const CAMERA_FRAMES_STREAM = "stream:camera_frames";
|
||||
export const AI_WORKER_CONSUMER_GROUP = "ai-worker";
|
||||
/**
|
||||
* Where entries go that cannot be processed and must not be retried - a
|
||||
* malformed task, or one that has been redelivered too many times. Separate
|
||||
* from the main stream so a poison message cannot block the queue and
|
||||
* cannot be lost silently either.
|
||||
*/
|
||||
export const CAMERA_FRAMES_DEAD_LETTER_STREAM = "stream:camera_frames:dead";
|
||||
export const PRINT_ALERTS_CHANNEL = "channel:print_alerts";
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Type, type Static, type TSchema } from "@sinclair/typebox";
|
||||
|
||||
/** Present but possibly null - a column that exists and has no value yet. */
|
||||
const Nullable = <T extends TSchema>(schema: T) => Type.Union([schema, Type.Null()]);
|
||||
|
||||
/**
|
||||
* What continuum-backend pushes down `/ws/telemetry` to an operator console,
|
||||
* mirroring `schemas/continuum/v1/app/telemetry_message.schema.json` by hand
|
||||
* - the JSON Schema is canonical, change it first.
|
||||
*
|
||||
* ## Why this is camelCase when everything else here is snake_case
|
||||
*
|
||||
* Because this one is not shared with the Rust gateway. `continuum/v1/edge/`
|
||||
* is snake_case so serde derives those names directly; this is the backend's
|
||||
* own HTTP vocabulary, and the browser already consumes camelCase from
|
||||
* `GET /printers`. Giving it a second model of a printer to reconcile would
|
||||
* be the cost of a consistency nobody benefits from.
|
||||
*
|
||||
* This file exists because a previous frontend invented a shape against
|
||||
* nothing - `printerId`, `nozzleTempC`, `progressPct`, `updatedAt` and a
|
||||
* closed `idle|printing|paused|error|offline` enum, inside an envelope no
|
||||
* backend ever sent. Both ends now mirror something.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A printer as the console displays it: the same shape `GET /printers/:id`
|
||||
* returns, so a client can apply a push to the row it already holds.
|
||||
*
|
||||
* Every telemetry field is nullable, and that is not laziness - a printer
|
||||
* that has never reported has no temperature, and "no data yet" is a
|
||||
* different thing from 0 degrees. A client that renders `?? 0` here will
|
||||
* show a cold printer as a real reading.
|
||||
*/
|
||||
export const AppPrinterStateSchema = Type.Object({
|
||||
id: Type.String({ format: "uuid" }),
|
||||
farmId: Type.String({ format: "uuid" }),
|
||||
name: Type.String(),
|
||||
vendor: Type.String(),
|
||||
status: Type.Optional(Nullable(Type.String())),
|
||||
/**
|
||||
* The printer's own word for what it is doing - a Bambu's `RUNNING`,
|
||||
* `PREPARE`, `FINISH`. Deliberately not an enum: an unrecognised state
|
||||
* must reach the screen as itself rather than being dropped on the way.
|
||||
*/
|
||||
vendorState: Type.Optional(Nullable(Type.String())),
|
||||
vendorErrorCode: Type.Optional(Nullable(Type.Integer())),
|
||||
nozzleTempC: Type.Optional(Nullable(Type.Number())),
|
||||
nozzleTargetTempC: Type.Optional(Nullable(Type.Number())),
|
||||
bedTempC: Type.Optional(Nullable(Type.Number())),
|
||||
bedTargetTempC: Type.Optional(Nullable(Type.Number())),
|
||||
progressPercent: Type.Optional(Nullable(Type.Integer())),
|
||||
currentLayer: Type.Optional(Nullable(Type.Integer())),
|
||||
totalLayers: Type.Optional(Nullable(Type.Integer())),
|
||||
remainingMinutes: Type.Optional(Nullable(Type.Integer())),
|
||||
currentJobName: Type.Optional(Nullable(Type.String())),
|
||||
/** A count of active HMS entries, not messages - we lack Bambu's tables. */
|
||||
activeAlerts: Type.Optional(Nullable(Type.Integer())),
|
||||
/**
|
||||
* The go2rtc stream name, when this printer has a camera that can actually
|
||||
* be restreamed. Null is the common case: only Bambu's X1/H2 class
|
||||
* publishes an RTSP URL. Present means `GET /printers/:id/stream` is worth
|
||||
* calling; null means render no player at all.
|
||||
*/
|
||||
cameraStream: Type.Optional(Nullable(Type.String())),
|
||||
/**
|
||||
* True when the camera can only be read as refreshing JPEG frames (Bambu
|
||||
* P1/A1); `GET /printers/:id/stream` then answers `kind: "snapshot"`.
|
||||
*/
|
||||
cameraSnapshot: Type.Optional(Type.Boolean()),
|
||||
/**
|
||||
* When a gateway last reported this printer; null means never. This is how
|
||||
* a client tells "idle" from "nobody has heard from it in an hour", which
|
||||
* the status on its own cannot say.
|
||||
*/
|
||||
lastSeenAt: Type.Optional(Nullable(Type.String())),
|
||||
});
|
||||
export type AppPrinterState = Static<typeof AppPrinterStateSchema>;
|
||||
|
||||
export const AppTelemetryMessageSchema = Type.Union([
|
||||
Type.Object(
|
||||
{
|
||||
type: Type.Literal("printer.telemetry"),
|
||||
printer: AppPrinterStateSchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
/**
|
||||
* Sent once, right after the socket authenticates.
|
||||
*
|
||||
* Worth a message of its own rather than letting the client infer
|
||||
* readiness from silence: an operator with no printers claimed and an
|
||||
* operator whose gateway is offline both see nothing arrive, and those two
|
||||
* need different things said to them.
|
||||
*/
|
||||
Type.Object(
|
||||
{
|
||||
type: Type.Literal("ready"),
|
||||
visiblePrinters: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
]);
|
||||
export type AppTelemetryMessage = Static<typeof AppTelemetryMessageSchema>;
|
||||
@@ -1,476 +0,0 @@
|
||||
import { Type, type Static, type TSchema } from "@sinclair/typebox";
|
||||
|
||||
/**
|
||||
* Optional *and* nullable: the key may be missing, or present as `null`.
|
||||
*
|
||||
* Strict in what we send, liberal in what we accept. A gateway omits fields
|
||||
* it has no value for, which is what the JSON Schema describes - but a
|
||||
* serde-derived Rust struct emits `null` by default unless every field is
|
||||
* annotated, and one such field reaching here would otherwise fail
|
||||
* validation and make Elysia drop an entire telemetry batch over a printer
|
||||
* that merely had no job name. Not worth risking a farm's telemetry on.
|
||||
*/
|
||||
const Maybe = <T extends TSchema>(schema: T) => Type.Optional(Type.Union([schema, Type.Null()]));
|
||||
|
||||
/**
|
||||
* The /ws/edge/v1 uplink protocol, mirroring
|
||||
* `schemas/continuum/v1/edge/*.schema.json` by hand - the same convention
|
||||
* every other schema in this package follows. The JSON Schema files are
|
||||
* canonical; change them first, then this, then the Rust mirror (which for
|
||||
* this one protocol lives in `continuum-proxy/src/uplink/protocol.rs` rather
|
||||
* than in `packages/rust-types`, since the gateway is its only Rust
|
||||
* consumer).
|
||||
*
|
||||
* Field names are snake_case, unlike the backend's HTTP routes. That is not
|
||||
* an oversight: the other end of this wire is serde-derived Rust, and
|
||||
* snake_case is what those structs produce without per-field rename
|
||||
* attributes. It is also what the rest of this repository uses.
|
||||
*/
|
||||
|
||||
export const PrinterTelemetrySchema = Type.Object({
|
||||
/**
|
||||
* The *gateway's* own id from its printers.toml - not a vendor serial and
|
||||
* not `printers.id`. The backend resolves it through
|
||||
* `printers.gateway_printer_id`, scoped to the authenticated farm.
|
||||
*/
|
||||
printer_id: Type.String(),
|
||||
name: Type.String(),
|
||||
/**
|
||||
* The printer's own state string rather than an enum, so an unrecognised
|
||||
* state survives the trip instead of being dropped at the edge. Bambu
|
||||
* emits IDLE, RUNNING, PAUSE, FINISH, FAILED.
|
||||
*/
|
||||
state: Maybe(Type.String()),
|
||||
percent: Maybe(Type.Integer({ minimum: 0, maximum: 100 })),
|
||||
/**
|
||||
* What the printer is doing when it is not laying down plastic - bed
|
||||
* levelling, nozzle cleaning, extrusion calibration. Bambu's `stg_cur`,
|
||||
* passed through as its own number because we do not have the
|
||||
* code-to-name table. 0 means printing.
|
||||
*
|
||||
* Without it, progress is ambiguous in a way that reads as a fault: a P1P
|
||||
* was observed at 14% with `layer` still 0 for several minutes while it
|
||||
* worked through stages 54, 14 and 13. A console showing that with no
|
||||
* stage looks stuck.
|
||||
*/
|
||||
stage: Maybe(Type.Integer()),
|
||||
/**
|
||||
* The stream name to ask the gateway's `stream_base_url` for, when this
|
||||
* printer has a camera the gateway can restream. Absent means no stream.
|
||||
*
|
||||
* Absent is the common answer today: a Bambu P1/A1 publishes no RTSP URL
|
||||
* at all - a real P1P's ipcam block has `ipcam_dev` and `ipcam_record` and
|
||||
* nothing to connect to - so only the X1/H2 class works this way. The rest
|
||||
* need Bambu's proprietary port-6000 JPEG stream, which no gateway
|
||||
* implements yet.
|
||||
*/
|
||||
camera_stream: Maybe(Type.String()),
|
||||
/**
|
||||
* The camera can only be read as single JPEG frames (Bambu P1/A1) and the
|
||||
* gateway answers `camera_snapshot_request` for it.
|
||||
*/
|
||||
camera_snapshot: Maybe(Type.Boolean()),
|
||||
layer: Maybe(Type.Integer({ minimum: 0 })),
|
||||
total_layers: Maybe(Type.Integer({ minimum: 0 })),
|
||||
remaining_minutes: Maybe(Type.Integer({ minimum: 0 })),
|
||||
job_name: Maybe(Type.String()),
|
||||
nozzle_temp: Maybe(Type.Number()),
|
||||
nozzle_target: Maybe(Type.Number()),
|
||||
bed_temp: Maybe(Type.Number()),
|
||||
bed_target: Maybe(Type.Number()),
|
||||
/** Vendor error code; non-zero means the printer is reporting a fault. */
|
||||
print_error: Maybe(Type.Integer()),
|
||||
/** Count of active Health-Management-System entries, not decoded messages. */
|
||||
active_alerts: Maybe(Type.Integer({ minimum: 0 })),
|
||||
});
|
||||
export type PrinterTelemetry = Static<typeof PrinterTelemetrySchema>;
|
||||
|
||||
/**
|
||||
* Deliberately small, and deliberately missing "start a print" - see the
|
||||
* JSON Schema for why that variant is absent until the gateway's
|
||||
* start-an-uploaded-file path has been run against real hardware by hand.
|
||||
*/
|
||||
/**
|
||||
* Every variant is closed (`additionalProperties: false`), unlike
|
||||
* `PrinterTelemetrySchema` which is deliberately open. The asymmetry is the
|
||||
* point, and it follows from who writes each message.
|
||||
*
|
||||
* Telemetry is written by gateways in the field, so it is read liberally: an
|
||||
* unknown field must not cost a whole farm's batch, and a gateway that is
|
||||
* newer than the backend should still be understood.
|
||||
*
|
||||
* Commands are written by this backend, are a handful of fields, and move
|
||||
* physical machines. Here an unrecognised field is a bug on the sending side,
|
||||
* and silently dropping it is the worst available outcome - a caller that
|
||||
* misspells a field would watch its command succeed and do something other
|
||||
* than what it asked for. `ams_mapping` is the concrete case: it is
|
||||
* deliberately not part of `start_print`, and a caller that passes one must
|
||||
* be told, not ignored.
|
||||
*/
|
||||
export const PrinterCommandSchema = Type.Union([
|
||||
Type.Object({
|
||||
action: Type.Literal("start_print"),
|
||||
/**
|
||||
* The cloud's `print_jobs.id`. The gateway does not need it to do the
|
||||
* work - it is here so a gateway's logs line up with a job.
|
||||
*/
|
||||
job_id: Type.String({ format: "uuid" }),
|
||||
/**
|
||||
* What to call the file on the printer's own storage. A `.3mf` is a
|
||||
* sliced project and its filament mapping is read out of the archive;
|
||||
* anything else is bare gcode and carries no mapping.
|
||||
*/
|
||||
file_name: Type.String({ minLength: 1, maxLength: 500 }),
|
||||
/**
|
||||
* A presigned, expiring GET for the job file - a URL rather than the
|
||||
* bytes, because the file is tens of megabytes and this is a JSON
|
||||
* control channel, and because it keeps object-store credentials out of
|
||||
* every gateway on every farm.
|
||||
*
|
||||
* Presign this at *dispatch* time, never at job creation: a queued job
|
||||
* may wait days and the URL would be long expired.
|
||||
*/
|
||||
url: Type.String({ format: "uri" }),
|
||||
/**
|
||||
* Use this AMS slot (0-based, across all units: AMS1 slot 4 is 3)
|
||||
* instead of the one the project's own filament colour would pick.
|
||||
* Only valid for a single-filament project, and the slot must hold the
|
||||
* same material type; the gateway refuses otherwise. Absent: map by the
|
||||
* project's filament.
|
||||
*/
|
||||
ams_slot: Type.Optional(Type.Integer({ minimum: 0, maximum: 15 })),
|
||||
}, { additionalProperties: false }),
|
||||
Type.Object({
|
||||
action: Type.Literal("set_fan_speed"),
|
||||
/**
|
||||
* A percentage on this wire whatever the vendor wants. A Bambu turns it
|
||||
* into an M106 PWM value and reports it back as one of 16 gears, so what
|
||||
* is set here does not read back identically.
|
||||
*/
|
||||
percent: Type.Integer({ minimum: 0, maximum: 100 }),
|
||||
}, { additionalProperties: false }),
|
||||
Type.Object({
|
||||
action: Type.Literal("control_print"),
|
||||
/**
|
||||
* Confirmed live on a P1P (2026-10-11). Beware what the printer's
|
||||
* acceptance means: all three answered `success` while it was IDLE with
|
||||
* nothing to pause, so confirmation that the state actually changed has
|
||||
* to come from telemetry, not from the command result.
|
||||
*
|
||||
* `stop` is a cancel and cannot be undone - a Bambu reports the
|
||||
* cancelled print as `FAILED`.
|
||||
*/
|
||||
control: Type.Union([
|
||||
Type.Literal("pause"),
|
||||
Type.Literal("resume"),
|
||||
Type.Literal("stop"),
|
||||
]),
|
||||
}, { additionalProperties: false }),
|
||||
Type.Object({
|
||||
action: Type.Literal("send_gcode_line"),
|
||||
/** The most powerful variant by far; be careful about exposing it to a UI. */
|
||||
line: Type.String(),
|
||||
}, { additionalProperties: false }),
|
||||
]);
|
||||
export type PrinterCommand = Static<typeof PrinterCommandSchema>;
|
||||
|
||||
const DiscoveredVendor = Type.Union([
|
||||
Type.Literal("bambu"),
|
||||
Type.Literal("klipper"),
|
||||
Type.Literal("prusa_link"),
|
||||
Type.Literal("octoprint"),
|
||||
Type.Literal("prusa_serial"),
|
||||
]);
|
||||
|
||||
/**
|
||||
* A printer a gateway found but cannot drive, because it is not in that
|
||||
* gateway's fleet file. 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,
|
||||
* and no probe learns either. What it buys is that an operator adding a
|
||||
* printer in the web app does not have to go and read an IP address or a
|
||||
* serial number off a machine.
|
||||
*
|
||||
* Every string here was chosen by a device on the customer's network -
|
||||
* anything that answers an SSDP query or an HTTP probe names itself - so
|
||||
* these are untrusted display strings. Hence the length caps, which are the
|
||||
* reason this one object is closed while its sibling variants are open: an
|
||||
* unbounded `name` from the LAN should not reach a database column or a
|
||||
* React tree, and a gateway has no legitimate reason to send extra fields
|
||||
* here that this backend would have to guess the meaning of.
|
||||
*/
|
||||
export const DiscoveredPrinterSchema = Type.Object({
|
||||
/**
|
||||
* The `vendor` value this candidate would need in a fleet file. Note
|
||||
* `octoprint` where a probe saw only the OctoPrint API: PrusaLink serves
|
||||
* that same API, so claiming PrusaLink would assert more than was
|
||||
* observed - see `ambiguous_with`.
|
||||
*/
|
||||
vendor: DiscoveredVendor,
|
||||
/**
|
||||
* An IP or hostname for a network printer, or a device path such as
|
||||
* `/dev/ttyACM0` for one on a serial port. An address on the *gateway's*
|
||||
* network: not a URL, and not reachable from the cloud.
|
||||
*/
|
||||
address: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
/** Only where a probe could learn it without a credential. */
|
||||
serial: Maybe(Type.String({ maxLength: 64 })),
|
||||
model: Maybe(Type.String({ maxLength: 128 })),
|
||||
/** Whatever the operator typed into their slicer. Display only. */
|
||||
name: Maybe(Type.String({ maxLength: 128 })),
|
||||
/** Human detail to recognise the machine by. Never parsed. */
|
||||
detail: Maybe(Type.String({ maxLength: 256 })),
|
||||
/**
|
||||
* A vendor this could equally be. Today only `prusa_link` versus
|
||||
* `octoprint`, which nothing on the network distinguishes. Present means
|
||||
* the operator must choose, and the web app should offer both rather than
|
||||
* quietly picking one.
|
||||
*/
|
||||
ambiguous_with: Maybe(DiscoveredVendor),
|
||||
/** Which probe found it, which bounds how much it can know. */
|
||||
source: Type.Union([
|
||||
Type.Literal("ssdp"),
|
||||
Type.Literal("certificate"),
|
||||
Type.Literal("http_api"),
|
||||
Type.Literal("usb_descriptor"),
|
||||
Type.Literal("serial_handshake"),
|
||||
]),
|
||||
/**
|
||||
* Whether this gateway build can actually drive this vendor. False means
|
||||
* the client is still a stub, so adding the printer would produce one that
|
||||
* connects and does nothing - worth saying in the UI rather than offering
|
||||
* it as ready.
|
||||
*/
|
||||
driver_ready: Type.Boolean(),
|
||||
}, { additionalProperties: false });
|
||||
export type DiscoveredPrinter = Static<typeof DiscoveredPrinterSchema>;
|
||||
|
||||
export const EdgeClientMessageSchema = Type.Union([
|
||||
Type.Object({
|
||||
type: Type.Literal("hello"),
|
||||
gateway_id: Type.String(),
|
||||
farm_id: Type.String(),
|
||||
/*
|
||||
* No credential here, deliberately. The edge JWT travels in an
|
||||
* `Authorization: Bearer` header on the upgrade request and is verified
|
||||
* before the socket exists. A `token` field used to live on this
|
||||
* message; 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` is redundant with the token's own claim, and is checked
|
||||
* against it rather than trusted.
|
||||
*/
|
||||
/**
|
||||
* Where this gateway serves camera restreams, e.g.
|
||||
* `http://gateway.local:1984`. Omitted by a gateway with no cameras or
|
||||
* no go2rtc.
|
||||
*
|
||||
* Reported rather than configured, because only the gateway knows the
|
||||
* address it is reachable at - and note what that address is: a LAN one.
|
||||
* A browser on the same network can play the stream; one outside it
|
||||
* cannot, and a relay for that does not exist yet.
|
||||
*/
|
||||
stream_base_url: Maybe(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
type: Type.Literal("heartbeat"),
|
||||
/** Monotonic per connection, restarting at 1 on reconnect. */
|
||||
seq: Type.Integer({ minimum: 0 }),
|
||||
}),
|
||||
Type.Object({
|
||||
type: Type.Literal("telemetry"),
|
||||
/**
|
||||
* A batch, not a message per printer: a farm has one uplink and many
|
||||
* printers, and a coherent picture of the farm at an instant is worth
|
||||
* more here than the lowest possible per-machine latency.
|
||||
*/
|
||||
printers: Type.Array(PrinterTelemetrySchema),
|
||||
}),
|
||||
Type.Object({
|
||||
type: Type.Literal("command_result"),
|
||||
command_id: Type.String(),
|
||||
ok: Type.Boolean(),
|
||||
/** Only when `ok` is false. A message for an operator, not a code. */
|
||||
error: Maybe(Type.String()),
|
||||
}),
|
||||
/** go2rtc's answer SDP for a `webrtc_offer`, passed through verbatim. */
|
||||
Type.Object({
|
||||
type: Type.Literal("webrtc_answer"),
|
||||
session_id: Type.String(),
|
||||
sdp: Type.String(),
|
||||
}),
|
||||
/**
|
||||
* No answer was obtainable: no such camera, go2rtc not running, or it
|
||||
* refused the offer. A variant of its own rather than an empty answer, so
|
||||
* a viewer is told something instead of watching a `<video>` that never
|
||||
* loads.
|
||||
*/
|
||||
Type.Object({
|
||||
type: Type.Literal("webrtc_error"),
|
||||
session_id: Type.String(),
|
||||
message: Type.String(),
|
||||
}),
|
||||
/** One JPEG frame from a `camera_snapshot_request`, or why there is none. */
|
||||
Type.Object({
|
||||
type: Type.Literal("camera_snapshot"),
|
||||
request_id: Type.String(),
|
||||
jpeg_base64: Maybe(Type.String()),
|
||||
error: Maybe(Type.String()),
|
||||
}),
|
||||
/**
|
||||
* What happened to a job the gateway was handed. Kept in the gateway's own
|
||||
* database and re-sent on every connection until answered with a
|
||||
* `job_event_ack`; the cloud applies it only to a job still in flight and
|
||||
* applying it twice changes nothing.
|
||||
*/
|
||||
Type.Object({
|
||||
type: Type.Literal("job_event"),
|
||||
event_id: Type.Integer(),
|
||||
job_id: Type.String({ format: "uuid" }),
|
||||
event: Type.Union([Type.Literal("started"), Type.Literal("finished"), Type.Literal("failed")]),
|
||||
/** For `failed`: the reason, in words for an operator. */
|
||||
detail: Maybe(Type.String()),
|
||||
/** When the gateway saw it, ms since the Unix epoch. */
|
||||
at_ms: Type.Integer(),
|
||||
}),
|
||||
Type.Object({
|
||||
type: Type.Literal("frame_batch"),
|
||||
printer_id: Type.String(),
|
||||
print_job_id: Maybe(Type.String({ format: "uuid" })),
|
||||
frames: Type.Array(
|
||||
Type.Object({
|
||||
object_key: Type.String(),
|
||||
captured_at: Type.String({ format: "date-time" }),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
/**
|
||||
* 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
|
||||
* hunting for an IP address.
|
||||
*
|
||||
* A whole batch that replaces whatever this gateway reported before, not a
|
||||
* set of additions: a printer that has gone is reported by its absence,
|
||||
* and a gateway that sees nothing sends an empty list. Only sent at all
|
||||
* when discovery is enabled in the gateway's own config, which it is not
|
||||
* by default - scanning a customer's network is something they ask for.
|
||||
*/
|
||||
Type.Object({
|
||||
type: Type.Literal("discovered_printers"),
|
||||
printers: Type.Array(DiscoveredPrinterSchema, { maxItems: 256 }),
|
||||
/**
|
||||
* When the scan finished, so the cloud can show the age of the list
|
||||
* rather than implying it is live.
|
||||
*/
|
||||
scanned_at: Maybe(Type.String({ format: "date-time" })),
|
||||
}),
|
||||
]);
|
||||
export type EdgeClientMessage = Static<typeof EdgeClientMessageSchema>;
|
||||
|
||||
export const EdgeServerMessageSchema = Type.Union([
|
||||
Type.Object({ type: Type.Literal("hello_ack"), session_id: Type.String() }),
|
||||
Type.Object({ type: Type.Literal("hello_error"), message: Type.String() }),
|
||||
Type.Object({ type: Type.Literal("heartbeat_ack"), seq: Type.Integer({ minimum: 0 }) }),
|
||||
/**
|
||||
* Relay a browser's WebRTC offer to this gateway's go2rtc and send the
|
||||
* answer back. **Signalling only - no video crosses this socket.** The
|
||||
* gateway POSTs the offer to its own go2rtc over loopback, which go2rtc
|
||||
* exempts from its API password, so no credential is needed or sent;
|
||||
* browser and go2rtc then negotiate media directly over ICE.
|
||||
*
|
||||
* It exists because a browser cannot signal to a gateway itself. Measured
|
||||
* against go2rtc 1.9.14: it sends no `Access-Control-Allow-Origin` on
|
||||
* anything, and checks its password before CORS, so the unauthenticated
|
||||
* preflight a browser always sends gets a 401.
|
||||
*
|
||||
* Relaying only the signalling is the point. The media stays
|
||||
* peer-to-peer - on a workshop LAN it never leaves the building and costs
|
||||
* no cloud egress - and go2rtc's answer carries a server-reflexive
|
||||
* candidate as well as its host ones, so a remote viewer can often
|
||||
* hole-punch straight to the gateway. A TURN server in go2rtc's
|
||||
* `ice_servers` covers the NATs where that fails, and that is
|
||||
* configuration rather than a change to this protocol.
|
||||
*/
|
||||
Type.Object({
|
||||
type: Type.Literal("webrtc_offer"),
|
||||
/** Correlates the answer with this offer. One negotiation, not a subscription. */
|
||||
session_id: Type.String(),
|
||||
/**
|
||||
* The go2rtc stream name, which is what `PrinterTelemetry.camera_stream`
|
||||
* reported. Deliberately not called a printer id: the two are equal
|
||||
* today, but one is the gateway's own naming and the other is ours.
|
||||
*/
|
||||
camera_stream: Type.String(),
|
||||
/** The browser's offer SDP, passed through verbatim. */
|
||||
sdp: Type.String(),
|
||||
}, { additionalProperties: false }),
|
||||
/**
|
||||
* Read one JPEG frame from a printer whose camera is not RTSP (Bambu P1/A1).
|
||||
* Unlike `webrtc_offer` the picture itself crosses the cloud: frames are
|
||||
* small and requested one at a time.
|
||||
*/
|
||||
Type.Object(
|
||||
{
|
||||
type: Type.Literal("camera_snapshot_request"),
|
||||
request_id: Type.String(),
|
||||
/** The gateway's own printer id. */
|
||||
printer_id: Type.String(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
/** The cloud has dealt with a `job_event`; the gateway can forget it. */
|
||||
Type.Object(
|
||||
{
|
||||
type: Type.Literal("job_event_ack"),
|
||||
event_id: Type.Integer(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object({
|
||||
type: Type.Literal("command"),
|
||||
command_id: Type.String(),
|
||||
/** The gateway's own printer id - the backend translates on the way out. */
|
||||
printer_id: Type.String(),
|
||||
command: PrinterCommandSchema,
|
||||
}),
|
||||
/**
|
||||
* Add a printer to this gateway. Relayed by the cloud, which keeps nothing:
|
||||
* it carries the printer's LAN credential, and printer credentials never
|
||||
* rest in the cloud. The gateway verifies them by connecting, stores them
|
||||
* in its own database, and answers with a `command_result` whose
|
||||
* `command_id` is `request_id`. **Never log this message.**
|
||||
*
|
||||
* No `ca_cert_path` and no serial port on purpose: both name things on the
|
||||
* gateway's own disk and USB bus, which the cloud does not get to choose.
|
||||
*/
|
||||
Type.Object(
|
||||
{
|
||||
type: Type.Literal("provision_printer"),
|
||||
request_id: Type.String(),
|
||||
printer: Type.Object(
|
||||
{
|
||||
/** The gateway's own printer id, chosen by the cloud. */
|
||||
id: Type.String({ pattern: "^[A-Za-z0-9_-]{1,64}$" }),
|
||||
name: Type.String({ minLength: 1, maxLength: 200 }),
|
||||
vendor: Type.String(),
|
||||
host: Type.Optional(Type.String({ maxLength: 255 })),
|
||||
access_code: Type.Optional(Type.String({ maxLength: 128 })),
|
||||
sn: Type.Optional(Type.String({ maxLength: 64 })),
|
||||
api_key: Type.Optional(Type.String({ maxLength: 256 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
/** Forget a printer: leave the running fleet and delete its stored credential. */
|
||||
Type.Object(
|
||||
{
|
||||
type: Type.Literal("remove_printer"),
|
||||
request_id: Type.String(),
|
||||
/** The gateway's own printer id. */
|
||||
printer_id: Type.String(),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
]);
|
||||
export type EdgeServerMessage = Static<typeof EdgeServerMessageSchema>;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -4,18 +4,4 @@
|
||||
// hand-written TypeBox schema here per file under schemas/continuum/v1/
|
||||
// once something actually consumes it (continuum-backend's validators,
|
||||
// most likely), same convention as before.
|
||||
|
||||
// The /ws/edge/v1 gateway uplink protocol - the first thing to actually need
|
||||
// a TypeBox mirror here, consumed by continuum-backend's edge WebSocket
|
||||
// route. Mirrors schemas/continuum/v1/edge/.
|
||||
export * from "./edge";
|
||||
|
||||
// The continuum-backend -> continuum-ai-worker interface: the
|
||||
// stream:camera_frames work queue and the channel:print_alerts notification
|
||||
// channel. Mirrors schemas/continuum/v1/ai/.
|
||||
export * from "./ai";
|
||||
|
||||
// The continuum-backend -> operator console push protocol: /ws/telemetry.
|
||||
// Mirrors schemas/continuum/v1/app/. camelCase, unlike the rest - see the
|
||||
// file for why.
|
||||
export * from "./app";
|
||||
export {};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
|
||||
@@ -10,11 +10,6 @@
|
||||
"properties": {
|
||||
"command": { "const": "push_status" },
|
||||
|
||||
"msg": {
|
||||
"type": "integer",
|
||||
"description": "Whether this push is the whole object or only what changed: 0 forced/full (what a pushing.pushall triggers), 1 automatic/partial. Confirmed against a real printer. A v1 consumer MUST read this - merging a partial push as if it were full leaves the picture complete, but replacing the cached object with a partial one silently unsets every field the push did not mention. It cannot be inferred any other way, because push_status does not echo sequence_id (see the module description), so there is no correlation back to the pushall that asked for it. v2 sends the whole object every time and this field is informational there."
|
||||
},
|
||||
|
||||
"upgrade_state": { "$ref": "../../common/upgrade_state.schema.json" },
|
||||
"ipcam": { "$ref": "../../common/ipcam.schema.json" },
|
||||
"upload": { "$ref": "../../common/upload.schema.json" },
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
{ "$ref": "../../../common/envelope.schema.json" },
|
||||
{
|
||||
"type": "object",
|
||||
"$comment": "There is deliberately no vt_tray property here. A real v2 printer omits the key entirely rather than sending an empty object, confirmed live, so absence means 'not reported' and not 'no external spool' - do not add it to make this symmetrical with v1. It is why the external-spool count cannot be derived from a report: counting reported spools would give 0 on exactly the dual-nozzle H2C that has two (see common/ams/ams_mapping.schema.json).",
|
||||
"properties": {
|
||||
"command": { "const": "push_status" },
|
||||
|
||||
|
||||
"2D": { "$ref": "./_2d_report.schema.json" },
|
||||
"3D": { "$ref": "./_3d_report.schema.json" },
|
||||
"ams": { "$ref": "../../common/ams/ams.schema.json" },
|
||||
@@ -87,10 +85,7 @@
|
||||
"mc_stage": { "type": "integer" },
|
||||
|
||||
"model_id": { "type": "string" },
|
||||
"msg": {
|
||||
"type": "integer",
|
||||
"description": "Full-vs-partial indicator, as in v1 (0 forced/full, 1 automatic/partial) - but informational here, because v2 sends the entire object on every push. The v1 schema carries the full note on why a v1 consumer must read it."
|
||||
},
|
||||
"msg": { "type": "integer" },
|
||||
|
||||
"percent": { "type": "integer" },
|
||||
"plate_cnt": { "type": "integer" },
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/common/envelope.schema.json",
|
||||
"title": "MessageEnvelope",
|
||||
"description": "Row provenance shared by the continuum/v1 domain types that $ref it: vendor, material, spool, printer_info, firmware_info and module_info. The _by fields are the actor's id, not a timestamp - they were typed as date-time here, copied from the _at lines above them, which would have rejected every real value the moment anything started validating against this.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"created_by": { "type": "string", "format": "uuid" },
|
||||
"created_by": { "type": "string", "format": "date-time" },
|
||||
"updated_at": { "type": "string", "format": "date-time" },
|
||||
"updated_by": { "type": "string", "format": "uuid" }
|
||||
"updated_by": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/ai/frame_task.schema.json",
|
||||
"title": "FrameTask",
|
||||
"description": "One camera still, handed from continuum-backend to continuum-ai-worker for print-failure scoring. Produced by XADD onto the stream named below; consumed by XREADGROUP.\n\nThis is a Valkey stream entry, not a JSON document: the wire form is a flat field/value map and EVERY value is a string. That is why there are no numeric or boolean properties here and why a field with no value is omitted from the XADD rather than written as an empty string - an empty string is a value, and a consumer that treats it as one ends up looking up the object key \"\".\n\nField names are snake_case, matching every other schema in this repository. The backend's HTTP routes are camelCase; this wire is not an HTTP route, and the consumer on the other end is serde-derived Rust.",
|
||||
"x-stream": "stream:camera_frames",
|
||||
"x-consumer-group": "ai-worker",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"farm_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "`farms.id`. Carried so a consumer can attribute load and alerts per farm without a database round trip."
|
||||
},
|
||||
"printer_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "`printers.id` - the backend's own UUID, already resolved from the gateway's printer id. NOT the gateway's `printers.toml` id, which means nothing outside its own farm."
|
||||
},
|
||||
"print_job_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "`print_jobs.id`, when the frame belongs to a tracked job. Omitted entirely when it does not - a printer may be streaming while idle, or running something queued outside Continuum. Consumers must treat an absent key and an empty value alike, and must not fabricate a job id."
|
||||
},
|
||||
"r2_object_key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Where the frame actually is, in the R2 bucket. The frame bytes are deliberately NOT in the stream: a stream entry is held in memory by the server and replayed to every consumer group, and a JPEG per printer per second would make the stream the largest thing in the deployment."
|
||||
},
|
||||
"captured_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When the camera took the frame, from the gateway's clock - not when the backend enqueued it. These can differ by a lot: a gateway buffers frames while its uplink is down, so a task may arrive minutes after the moment it describes."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"farm_id",
|
||||
"printer_id",
|
||||
"r2_object_key",
|
||||
"captured_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/ai/print_alert.schema.json",
|
||||
"title": "PrintAlert",
|
||||
"description": "A print-failure detection, published by continuum-ai-worker for live operator consoles.\n\nUnlike FrameTask this one IS a JSON document: it travels as the payload of a Valkey PUBLISH, which carries a single opaque string, so the structure has to be inside it.\n\nPUBLISH is fire-and-forget with no persistence and no delivery guarantee: an alert published while nothing is subscribed is gone. That is acceptable for the live-console path it feeds and is NOT acceptable as the record of a detection, which belongs in Postgres. As of 2026-10-11 nothing subscribes to this channel and nothing persists it - see the README section on the AI path.",
|
||||
"x-channel": "channel:print_alerts",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"printer_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "`printers.id`, taken from the frame task that produced the detection."
|
||||
},
|
||||
"print_job_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Omitted when the frame was not attributed to a tracked job."
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "What the model thinks it saw, e.g. \"spaghetti\", \"layer_shift\", \"detachment\". A free string rather than an enum on purpose: the label set is a property of the model file, which changes without this schema changing."
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "The model's own score. Whether it is high enough to act on is the consumer's policy decision, not the worker's - the worker publishes what it found and does not threshold on behalf of an operator who may want to see near-misses."
|
||||
},
|
||||
"captured_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "From the frame, not from the detection. An operator needs to know when the printer looked like this, not when a GPU got round to looking."
|
||||
},
|
||||
"r2_object_key": {
|
||||
"type": "string",
|
||||
"description": "The frame the detection came from, so a console can show the operator the picture rather than only a label."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"printer_id",
|
||||
"label",
|
||||
"confidence",
|
||||
"captured_at",
|
||||
"r2_object_key"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/app/telemetry_message.schema.json",
|
||||
"title": "AppTelemetryMessage",
|
||||
"description": "What continuum-backend pushes down /ws/telemetry to an operator console. Discriminated on 'type'. NOTE the casing: camelCase, unlike continuum/v1/edge/ and every other schema here, because this is the backend's own HTTP vocabulary rather than a format shared with the Rust gateway - the browser already consumes camelCase from GET /printers and should not have to hold two models of a printer. A previous frontend invented its own shape against nothing (printerId, nozzleTempC, progressPct, a closed state enum) and no backend ever sent it; this file exists so both ends have something to mirror.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"title": "PrinterTelemetryPush",
|
||||
"description": "One printer's current state, sent whenever its gateway reports a change. The payload is deliberately the same shape GET /printers/:id returns, so a client can apply it to the row it already has rather than reconciling two vocabularies. Only printers in an organisation the authenticated user belongs to are ever sent.",
|
||||
"properties": {
|
||||
"type": { "const": "printer.telemetry" },
|
||||
"printer": { "$ref": "#/$defs/PrinterState" }
|
||||
},
|
||||
"required": ["type", "printer"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Ready",
|
||||
"description": "Sent once, immediately after the socket authenticates, naming how many printers the user can see. Worth its own message rather than leaving the client to infer readiness from silence: an operator with no printers claimed and an operator whose gateway is offline both see nothing arrive, and those need different things said to them.",
|
||||
"properties": {
|
||||
"type": { "const": "ready" },
|
||||
"visiblePrinters": { "type": "integer", "minimum": 0 }
|
||||
},
|
||||
"required": ["type", "visiblePrinters"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"PrinterState": {
|
||||
"type": "object",
|
||||
"description": "A printer as the console displays it. Every telemetry field is nullable because a printer that has never reported has no temperature, and 'no data yet' is not 0 degrees.",
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"farmId": { "type": "string", "format": "uuid" },
|
||||
"name": { "type": "string" },
|
||||
"vendor": { "type": "string" },
|
||||
"status": {
|
||||
"type": ["string", "null"],
|
||||
"description": "The backend's own narrowed status, which the UI filters and groups on."
|
||||
},
|
||||
"vendorState": {
|
||||
"type": ["string", "null"],
|
||||
"description": "The printer's own word for what it is doing - a Bambu's 'RUNNING', 'PREPARE', 'FINISH'. Kept alongside 'status' rather than replaced by it, because the narrowed enum necessarily loses detail an operator sometimes needs. Not an enum here on purpose: an unrecognised state must reach the screen as itself rather than being dropped."
|
||||
},
|
||||
"vendorErrorCode": { "type": ["integer", "null"] },
|
||||
"nozzleTempC": { "type": ["number", "null"] },
|
||||
"nozzleTargetTempC": { "type": ["number", "null"] },
|
||||
"bedTempC": { "type": ["number", "null"] },
|
||||
"bedTargetTempC": { "type": ["number", "null"] },
|
||||
"progressPercent": { "type": ["integer", "null"] },
|
||||
"currentLayer": { "type": ["integer", "null"] },
|
||||
"totalLayers": { "type": ["integer", "null"] },
|
||||
"remainingMinutes": { "type": ["integer", "null"] },
|
||||
"currentJobName": { "type": ["string", "null"] },
|
||||
"activeAlerts": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "How many Health-Management-System entries the printer currently has active. A count, not messages: decoding them needs Bambu's HMS tables, which Continuum does not have."
|
||||
},
|
||||
"cameraStream": {
|
||||
"type": ["string", "null"],
|
||||
"description": "The go2rtc stream name, when this printer has a camera that can actually be restreamed. Null is the common case - only Bambu's X1/H2 class publishes an RTSP URL. Present means it is worth calling GET /printers/:id/stream; null means render no player."
|
||||
},
|
||||
"cameraSnapshot": {
|
||||
"type": "boolean",
|
||||
"description": "True when the camera can only be read as refreshing JPEG frames (Bambu P1/A1); GET /printers/:id/stream then answers kind 'snapshot'."
|
||||
},
|
||||
"lastSeenAt": {
|
||||
"type": ["string", "null"],
|
||||
"format": "date-time",
|
||||
"description": "When a gateway last reported this printer. Null means never. This is how a client tells 'idle' from 'nobody has heard from it in an hour', which the status alone cannot say."
|
||||
}
|
||||
},
|
||||
"required": ["id", "farmId", "name", "vendor"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
{
|
||||
"$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": "CameraSnapshot",
|
||||
"description": "The answer to a CameraSnapshotRequest: one JPEG frame (base64), or the reason there is none.",
|
||||
"properties": {
|
||||
"type": { "const": "camera_snapshot" },
|
||||
"request_id": { "type": "string" },
|
||||
"jpeg_base64": { "type": "string" },
|
||||
"error": { "type": "string", "description": "Set instead of jpeg_base64 when no frame could be read." }
|
||||
},
|
||||
"required": ["type", "request_id"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "JobEvent",
|
||||
"description": "What happened to a job this gateway was handed, kept in the gateway's own database and re-sent on every connection until the cloud answers with a JobEventAck - so an outcome reached while the cloud was unreachable (or the gateway restarted) still arrives. The cloud applies it only to a job that is still in flight, so a job it has since cancelled or reassigned is left alone, and applying the same event twice changes nothing.",
|
||||
"properties": {
|
||||
"type": { "const": "job_event" },
|
||||
"event_id": { "type": "integer", "description": "The gateway's own sequence number for this event; echoed in the JobEventAck." },
|
||||
"job_id": { "type": "string", "format": "uuid" },
|
||||
"event": { "enum": ["started", "finished", "failed"] },
|
||||
"detail": { "type": ["string", "null"], "description": "For failed: the reason, in words for an operator." },
|
||||
"at_ms": { "type": "integer", "description": "When the gateway observed it, milliseconds since the Unix epoch." }
|
||||
},
|
||||
"required": ["type", "event_id", "job_id", "event", "at_ms"]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://schemas.continuum.dev/continuum/v1/edge/discovered_printer.schema.json",
|
||||
"title": "DiscoveredPrinter",
|
||||
"description": "A printer a gateway found on its own network or USB ports that is NOT in its fleet file - so it is a candidate for an operator to add, not a printer the gateway can drive. It deliberately carries no credential: a Bambu access code is on the printer's screen and a PrusaLink API key is in its web UI, and no probe can learn either. The point of sending these to the cloud is that an operator adding a printer in the web app should not have to go and read an IP address or a 15-character serial off a machine. Every string here is reported by a device on the customer's network and must be treated as untrusted display text - hence the length caps - because anything that answers an SSDP query or an HTTP probe chooses its own name.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"description": "The `vendor` value this candidate would need in the gateway's fleet file. `octoprint` rather than `prusa_link` is the honest answer when a probe saw only the OctoPrint API, which PrusaLink also serves - see `ambiguous_with`.",
|
||||
"type": "string",
|
||||
"enum": ["bambu", "klipper", "prusa_link", "octoprint", "prusa_serial"]
|
||||
},
|
||||
"address": {
|
||||
"description": "Where the gateway found it: an IP or hostname for a network printer, or a device path like `/dev/ttyACM0` for one on a serial port. Not a URL, and not reachable from the cloud - it is an address on the gateway's network, shown so an operator can recognise which machine this is.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
},
|
||||
"serial": {
|
||||
"description": "The printer's serial number where the probe could learn it without a credential - which today means a Bambu printer's TLS certificate CN, or a Marlin board's `UUID`. Absent is normal and not an error.",
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"model": {
|
||||
"description": "The vendor's own model code or machine type, e.g. Bambu's `O1C2` or Marlin's `MACHINE_TYPE`.",
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"name": {
|
||||
"description": "The name the printer reports for itself, which is usually whatever the operator typed into their slicer or set as the host's hostname. Display only.",
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"detail": {
|
||||
"description": "Free-form human detail to recognise the machine by - a firmware version, a USB vendor string, or why a probe could not say more. Never parsed, and never written into a config file.",
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
},
|
||||
"ambiguous_with": {
|
||||
"description": "A vendor this could equally be, when the probe genuinely cannot tell. Today only `prusa_link` versus `octoprint`: PrusaLink implements OctoPrint's API, so without a credential nothing on the network distinguishes them. Present means the operator has to choose; the web app should offer both rather than picking one.",
|
||||
"type": "string",
|
||||
"enum": ["bambu", "klipper", "prusa_link", "octoprint", "prusa_serial"]
|
||||
},
|
||||
"source": {
|
||||
"description": "Which probe found it, which bounds how much it can know: `certificate` and `usb_descriptor` are passive reads, `ssdp` is an unsolicited announcement, `http_api` an identity endpoint, and `serial_handshake` an `M115` that reset the board to ask.",
|
||||
"type": "string",
|
||||
"enum": ["ssdp", "certificate", "http_api", "usb_descriptor", "serial_handshake"]
|
||||
},
|
||||
"driver_ready": {
|
||||
"description": "Whether this gateway build can actually drive this vendor. False means the printer was found and reported but its client is still a stub, so adding it would produce a printer that connects and does nothing - the web app should say so rather than offering it as ready to use.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["vendor", "address", "source", "driver_ready"]
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/edge/printer_command.schema.json",
|
||||
"title": "PrinterCommand",
|
||||
"description": "The closed set of things the cloud may ask a printer to do. Deliberately small. It now includes start_print, which was held back until there was a job path to drive it; the thing to understand about that variant is that its acceptance says almost nothing - see its own description. Every variant is closed (additionalProperties: false), unlike printer_telemetry which is deliberately open: telemetry is written by gateways in the field and read liberally, while a command is written by the backend, is a handful of fields, and moves a physical machine - an unrecognised field there is a bug on the sending side, and silently dropping it would let a caller misspell a field and watch its command succeed while doing something else.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"title": "StartPrint",
|
||||
"description": "Print a job's file. The gateway downloads it from `url`, transfers it to the printer, and starts it. Three things about this variant that a caller must not get wrong. (1) Its result means the gateway accepted the job, not that anything is printing: a real end-to-end run on a P1P (2026-10-15) was accepted, uploaded byte-perfect, reached RUNNING, and then the printer paused itself on a physical filament fault. Job state has to be driven by the telemetry that follows, never by this reply. (2) There is no filament or AMS field on purpose. The gateway reads the mapping out of the project archive's own Metadata/slice_info.config, because the archive is the only thing that knows what each plate was sliced for - guessing it instead is what made that same printer heat to 250C and pause itself at 1%. (3) `url` is short-lived and must be presigned at dispatch time, not at job creation: a queued job may wait days, and a URL minted when it was queued would be expired by the time anything sent it.",
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "start_print"
|
||||
},
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "The cloud's print_jobs.id. The gateway does not need it to do the work - it is here so that a gateway's logs can be lined up against a job without reading its own telemetry back out of the cloud."
|
||||
},
|
||||
"file_name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500,
|
||||
"description": "What to call the file on the printer's own storage. A `.3mf` is treated as a sliced project (filament mapping derived from inside it); anything else is treated as bare gcode, which carries no mapping and prints with whatever its own tool changes select."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "A presigned, expiring GET for the job file. Deliberately a URL rather than the bytes: the file is tens of megabytes, this is a JSON control channel, and handing the gateway a URL keeps object-store credentials out of every gateway on every farm. Expect it to be valid for minutes, and note that an upload to a printer is slow - 0.2 MiB/s measured over FTPS to a P1P - so the download must finish before the transfer starts, not stream through it."
|
||||
},
|
||||
"ams_slot": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 15,
|
||||
"description": "Use this AMS slot (0-based across all units: AMS1 slot 4 is 3) instead of the one the project's own filament colour would pick. Only valid for a single-filament project, and the slot must hold the same material type; the gateway refuses otherwise."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"job_id",
|
||||
"file_name",
|
||||
"url"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "SetFanSpeed",
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "set_fan_speed"
|
||||
},
|
||||
"percent": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"description": "A percentage on this wire regardless of what the vendor wants. Each gateway converts: for Bambu that means an M106 PWM value over MQTT, which the printer then reports back as one of 16 fan gears - so a value set here does not read back identically."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"percent"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "ControlPrint",
|
||||
"description": "Pause, resume or stop the print the printer is currently running. Confirmed live on a P1P (2026-10-11): all three are accepted and a stop during PREPARE/RUNNING moved the printer to FAILED, which is how Bambu reports an operator-cancelled print. Note what the printer's acceptance does not mean - all three answered success while it was IDLE with nothing to pause, so a caller that needs to know the state actually changed must read telemetry back rather than trust the command result.",
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "control_print"
|
||||
},
|
||||
"control": {
|
||||
"enum": [
|
||||
"pause",
|
||||
"resume",
|
||||
"stop"
|
||||
],
|
||||
"description": "Stop is a cancel, not a pause: it ends the print and it cannot be undone. A UI should treat it accordingly."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"control"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "SendGcodeLine",
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "send_gcode_line"
|
||||
},
|
||||
"line": {
|
||||
"type": "string",
|
||||
"description": "A single raw gcode line. By far the most powerful variant, and the one to be most careful about ever exposing to a UI."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action",
|
||||
"line"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/edge/printer_telemetry.schema.json",
|
||||
"title": "PrinterTelemetry",
|
||||
"description": "A flattened snapshot of one printer, in the terms an operator console displays. Deliberately not any vendor's native status shape: it has to describe a Bambu, a Prusa and a Klipper printer alike, and Bambu's wire format in particular has traps that should not reach the rest of the system (fan speeds are 0-15 gears rather than a percent, temperatures arrive as either integers or floats, and protocol v2 reuses one sequence_id indefinitely). Every field but printer_id and name is optional, because a printer that is unreachable or has only just connected genuinely has nothing to report, and 'no data yet' is not the same as zero.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"printer_id": {
|
||||
"type": "string",
|
||||
"description": "The gateway's own id for this printer, from its printers.toml - NOT the vendor serial number and NOT the backend's printers.id UUID. The backend resolves it to a UUID via printers.gateway_printer_id, scoped to the farm the gateway authenticated as."
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"state": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The printer's own state string, passed through rather than narrowed to an enum here, so an unrecognised state reaches the backend as itself instead of being dropped at the edge. Bambu emits IDLE, RUNNING, PAUSE, FINISH and FAILED. 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."
|
||||
},
|
||||
"percent": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"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."
|
||||
},
|
||||
"stage": {
|
||||
"type": "integer",
|
||||
"description": "The printer's current stage, when it is doing something other than laying down plastic: bed levelling, nozzle cleaning, extrusion calibration and so on. Bambu's `stg_cur`, passed through as the printer's own number rather than decoded, because the code-to-name table is not something we have. 0 means printing.\n\nWithout this, progress is ambiguous in a way that reads as a fault. Confirmed on a P1P (2026-10-11): `percent` climbed to 14% while `layer` stayed 0 for several minutes, because the printer was working through its pre-print stages - it reported `stg_cur` 54, then 14, then 13, then 0, and only then began layer 1. A console showing 14% at layer 0 with no stage looks stuck."
|
||||
},
|
||||
"layer": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"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."
|
||||
},
|
||||
"total_layers": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"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."
|
||||
},
|
||||
"remaining_minutes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"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."
|
||||
},
|
||||
"job_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Normalised at the edge: a Bambu reports an empty string rather than omitting the field when idle, and the gateway turns that into an absent value so consumers need not know it. 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."
|
||||
},
|
||||
"nozzle_temp": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"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."
|
||||
},
|
||||
"nozzle_target": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"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."
|
||||
},
|
||||
"bed_temp": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"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."
|
||||
},
|
||||
"bed_target": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"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."
|
||||
},
|
||||
"print_error": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "Vendor error code. Non-zero means the printer is reporting a fault. 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."
|
||||
},
|
||||
"active_alerts": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"description": "How many Health-Management-System entries the printer currently has active. A count only for now - decoding the {attr, code} pairs into messages needs Bambu's HMS tables, which Continuum does not have. 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."
|
||||
},
|
||||
"camera_stream": {
|
||||
"type": "string",
|
||||
"description": "The stream name to ask the gateway's stream_base_url for, when this printer has a camera the gateway could restream. Absent means no stream: either the printer reports no camera source, or it is a model whose camera is not reachable this way. That second case is the common one today - a Bambu P1/A1 does not publish an RTSP URL at all (confirmed: a P1P's ipcam block has ipcam_dev and ipcam_record and no rtsp_url), and only the X1/H2 class does. Those are served by camera_snapshot instead."
|
||||
},
|
||||
"camera_snapshot": {
|
||||
"type": "boolean",
|
||||
"description": "True when this printer's camera can only be read as single JPEG frames (a Bambu P1/A1: proprietary TLS stream on port 6000, no RTSP) and the gateway will answer a CameraSnapshotRequest for it. Mutually exclusive with camera_stream in practice. Absent means false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"printer_id",
|
||||
"name"
|
||||
]
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/edge/server_message.schema.json",
|
||||
"title": "EdgeServerMessage",
|
||||
"description": "What continuum-backend sends back down the same WebSocket. Discriminated on 'type'; snake_case for the same reason as EdgeClientMessage.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"title": "HelloAck",
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "hello_ack"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"session_id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "HelloError",
|
||||
"description": "The handshake was rejected. The connection closes immediately after this.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "hello_error"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"message"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "HeartbeatAck",
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "heartbeat_ack"
|
||||
},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Echoes the seq from the heartbeat being acknowledged."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"seq"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Command",
|
||||
"description": "Do something to one printer on this gateway. Answered with a client command_result carrying the same command_id.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "command"
|
||||
},
|
||||
"command_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"printer_id": {
|
||||
"type": "string",
|
||||
"description": "The gateway's own printer id, not the backend UUID - the backend translates on the way out, so a gateway never has to know its printers' cloud identities."
|
||||
},
|
||||
"command": {
|
||||
"$ref": "./printer_command.schema.json"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"command_id",
|
||||
"printer_id",
|
||||
"command"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "WebrtcOffer",
|
||||
"description": "Relay a browser's WebRTC offer to this gateway's go2rtc and send the answer back. SIGNALLING ONLY - no video crosses this socket. The gateway POSTs the offer to its own go2rtc over loopback (which go2rtc exempts from its API password, so no credential is needed or sent) and returns the answer as a WebrtcAnswer, after which the browser and go2rtc negotiate media directly over ICE. This exists because a browser cannot signal to a gateway itself: measured against go2rtc 1.9.14, it sends no CORS headers on anything and checks its password before CORS, so the unauthenticated preflight a browser always sends gets a 401. Relaying only the signalling keeps the media peer-to-peer - on a workshop LAN it never leaves the building, and go2rtc also offers a server-reflexive candidate, so a remote viewer can often hole-punch straight to the gateway. A TURN server in go2rtc's ice_servers is the fallback for the NATs where that fails; it is configuration, not a change to this protocol.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "webrtc_offer"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "Correlates the answer with this offer. One negotiation, not a lasting subscription."
|
||||
},
|
||||
"camera_stream": {
|
||||
"type": "string",
|
||||
"description": "The go2rtc stream name, which is what PrinterTelemetry.camera_stream reported. Deliberately not called a printer id: the two are equal today, but one is the gateway's own naming and the other is the cloud's."
|
||||
},
|
||||
"sdp": {
|
||||
"type": "string",
|
||||
"description": "The browser's offer SDP, passed through verbatim."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"session_id",
|
||||
"camera_stream",
|
||||
"sdp"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "CameraSnapshotRequest",
|
||||
"description": "Read one JPEG frame from a printer's camera and send it back as a CameraSnapshot. For the printers whose camera is not RTSP (Bambu P1/A1, telemetry camera_snapshot true). Unlike WebrtcOffer this DOES carry the picture through the cloud, which is acceptable only because the frames are small, low-rate and requested one at a time.",
|
||||
"properties": {
|
||||
"type": { "const": "camera_snapshot_request" },
|
||||
"request_id": { "type": "string" },
|
||||
"printer_id": { "type": "string", "description": "The gateway's own printer id." }
|
||||
},
|
||||
"required": ["type", "request_id", "printer_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "JobEventAck",
|
||||
"description": "The cloud has dealt with a JobEvent (applied it, or found it had nothing to change), so the gateway can forget it. Not sent when the cloud could not record it, so the gateway sends it again.",
|
||||
"properties": {
|
||||
"type": { "const": "job_event_ack" },
|
||||
"event_id": { "type": "integer" }
|
||||
},
|
||||
"required": ["type", "event_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "ProvisionPrinter",
|
||||
"description": "Add a printer to this gateway. Sent by the console through the cloud, which relays it and keeps nothing: it carries the printer's LAN credential (access_code or api_key), and the platform's rule is that those never rest in the cloud. The gateway checks them by connecting to the printer, saves them to its own local store, and answers with a client command_result whose command_id equals request_id. NEVER LOG THIS MESSAGE. Deliberately has no ca_cert_path or serial port: those name things on the gateway's own disk and USB bus, which the cloud does not get to choose.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "provision_printer"
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string",
|
||||
"description": "Echoed as command_id in the command_result."
|
||||
},
|
||||
"printer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{1,64}$",
|
||||
"description": "The gateway's own printer id, chosen by the cloud so that telemetry is reported under an id the cloud already knows."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"description": "A fleet vendor name: bambu, bambu_v1, bambu_v2, prusa_link, octoprint, klipper."
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"access_code": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"sn": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"vendor"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"request_id",
|
||||
"printer"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "RemovePrinter",
|
||||
"description": "Forget a printer on this gateway: drop it from the running fleet and delete its stored credential. Answered with a command_result whose command_id equals request_id.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "remove_printer"
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"printer_id": {
|
||||
"type": "string",
|
||||
"description": "The gateway's own printer id."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"request_id",
|
||||
"printer_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"composite": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
+14
-8
@@ -1,10 +1,16 @@
|
||||
{
|
||||
// A solution file: it builds nothing itself and only points at the real
|
||||
// projects. It used to carry the compilerOptions directly with no `include`,
|
||||
// which made `tsc -b` compile every .ts file in the repository as one project
|
||||
// and emit .js/.d.ts next to the sources - overriding each package's own
|
||||
// `outDir`, and leaving generated files in src/ that then got committed.
|
||||
// Shared options now live in tsconfig.base.json, which each package extends.
|
||||
"files": [],
|
||||
"references": [{ "path": "./packages/ts-types" }]
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"composite": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": ["examples", "node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user