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.
This commit is contained in:
2026-10-19 08:04:27 +00:00
parent 1386b1b4db
commit 6ad400d44c
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>;