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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user