Receive and serve a gateway's printer discovery results

The gateway can find printers on its own network and USB ports; nothing
carried that back. So adding a printer in the console still began with
someone walking to a machine to read its IP address off the screen.

Three pieces:

- A `discovered_printers` handler on /ws/edge/v1, recording the latest scan
  per farm.
- `discoveredByFarm`, one entry per farm, replaced wholesale by each report.
- `GET /farms/:id/discovered-printers`, viewer-gated like its unclaimed
  sibling.

Why a separate store rather than folding this into `unclaimedByFarm`: an
unclaimed printer is already in the gateway's `printers.toml`, so someone has
written down its address and credential and the gateway is talking to it -
all that is missing is the row. A discovered printer was merely seen. Nobody
configured it, no credential for it exists anywhere, and the gateway cannot
reach it as a printer. One is a row waiting to be created; the other is a
suggestion. Keyed per farm rather than per printer because discovery has no
stable key to merge on: a Bambu printer has a serial, a Klipper host has only
the address DHCP gave it this week, and merging on that accumulates a phantom
candidate per lease.

The route cross-checks candidates against the farm's existing printer rows so
the console can grey one out instead of offering a duplicate - the gateway
filters its own fleet file but knows nothing about rows registered in the
cloud and never claimed. `matchedOn` reports the basis because the two bases
are not equally sure: a serial identifies a machine, an address identifies a
DHCP lease and may well be last month's printer.

`discovery: null` means the gateway has never reported a scan, which is the
normal case because discovery is off by default in the gateway's config.
That is deliberately distinguishable from a report whose `printers` is empty,
which means it looked and found nothing.

The protocol tests pin the parts that matter for safety: an over-long name is
refused, and so is a candidate carrying an `access_code`. That second one is
not tidiness - no probe can learn an access code, so a candidate that has one
got it elsewhere, and the closed object is what stops this message quietly
becoming a channel for secrets nobody meant to send to the cloud.
This commit is contained in:
2026-10-19 08:07:22 +00:00
parent dddfff6800
commit cd7aed0028
3 changed files with 325 additions and 2 deletions
+105
View File
@@ -140,6 +140,111 @@ describe("gateway -> backend frames", () => {
expect(Value.Check(EdgeClientMessageSchema, { type: "hello", gateway_id: "g" })).toBe(false);
expect(Value.Check(EdgeClientMessageSchema, { type: "hello", farm_id: "f" })).toBe(false);
});
describe("discovered_printers", () => {
const candidate = {
vendor: "bambu",
address: "192.168.0.20",
serial: "00M09A382100000",
model: "C11",
name: "P1P",
source: "certificate",
driver_ready: true,
};
it("accepts a scan result", () => {
check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [candidate],
scanned_at: "2026-10-19T12:00:00Z",
});
});
it("accepts a gateway that looked and found nothing", () => {
// Distinct from never having reported, which is the off-by-default
// case. An empty list is a real answer and must validate.
check(EdgeClientMessageSchema, { type: "discovered_printers", printers: [] });
});
it("accepts a candidate with nothing but where and what it is", () => {
// The honest minimum for a Klipper host: Moonraker answers, so the
// address and the vendor are known, and nothing else is.
check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [{ vendor: "klipper", address: "192.168.0.31", source: "http_api", driver_ready: false }],
});
});
it("accepts the prusa_link / octoprint ambiguity", () => {
check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [
{
vendor: "octoprint",
address: "192.168.0.42",
ambiguous_with: "prusa_link",
source: "http_api",
driver_ready: false,
},
],
});
});
it("rejects a name longer than the cap", () => {
// The point of the cap, and the reason this variant is closed where
// its siblings are open: every string in a candidate was chosen by a
// device on the customer's network. Anything that answers an SSDP
// query names itself, so an unbounded `name` is LAN-supplied text on
// its way to a database column and a React tree.
expect(
Value.Check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [{ ...candidate, name: "x".repeat(129) }],
}),
).toBe(false);
});
it("rejects a vendor nothing can drive and a source nothing can produce", () => {
for (const bad of [{ vendor: "makerbot" }, { source: "telepathy" }]) {
expect(
Value.Check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [{ ...candidate, ...bad }],
}),
).toBe(false);
}
});
it("rejects a candidate carrying a credential", () => {
// Not a hypothetical tidiness check. A probe cannot learn an access
// code, so a candidate that has one got it from somewhere else, and
// the closed object is what stops this message quietly becoming a
// channel for secrets nobody meant to send to the cloud.
expect(
Value.Check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [{ ...candidate, access_code: "12345678" }],
}),
).toBe(false);
});
it("requires an address, because a candidate without one is unusable", () => {
const { address: _address, ...addressless } = candidate;
expect(
Value.Check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [addressless],
}),
).toBe(false);
// And an empty one is no better than a missing one.
expect(
Value.Check(EdgeClientMessageSchema, {
type: "discovered_printers",
printers: [{ ...candidate, address: "" }],
}),
).toBe(false);
});
});
});
describe("backend -> gateway frames", () => {