Add a discovered_printers gateway message

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>
This commit is contained in:
2026-10-19 08:04:27 +00:00
co-authored by Claude Opus 5
parent ab9274497e
commit 71f8ea94b7
3 changed files with 171 additions and 0 deletions
+90
View File
@@ -161,6 +161,76 @@ export const PrinterCommandSchema = Type.Union([
]);
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"),
@@ -237,6 +307,26 @@ export const EdgeClientMessageSchema = Type.Union([
}),
),
}),
/**
* 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>;
@@ -182,6 +182,32 @@
"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"
]
}
]
}
@@ -0,0 +1,55 @@
{
"$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"]
}