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:
+99
-2
@@ -2,11 +2,11 @@ import { Elysia, t } from "elysia";
|
|||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { NotFoundError } from "@continuum/ts-core";
|
import { NotFoundError } from "@continuum/ts-core";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { farms } from "../db/schema";
|
import { farms, printers } from "../db/schema";
|
||||||
import { farmProjection } from "../db/projections";
|
import { farmProjection } from "../db/projections";
|
||||||
import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth";
|
import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth";
|
||||||
import { PaginationQuery, paginate } from "./pagination";
|
import { PaginationQuery, paginate } from "./pagination";
|
||||||
import { isGatewayOnline, listUnclaimedPrinters } from "./ws/edge";
|
import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters } from "./ws/edge";
|
||||||
|
|
||||||
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
|
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
|
||||||
|
|
||||||
@@ -210,4 +210,101 @@ export const farmRoutes = new Elysia({ prefix: "/farms" })
|
|||||||
summary: "Printers the gateway reports that no printer row claims yet",
|
summary: "Printers the gateway reports that no printer row claims yet",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Printers the gateway *found* on its own network and USB ports, which is
|
||||||
|
* a different thing from the list above.
|
||||||
|
*
|
||||||
|
* An unclaimed printer is in the gateway's fleet file already: its address
|
||||||
|
* and credential were written down by hand, and only the printer row is
|
||||||
|
* missing. A discovered one was merely seen. The point of it is that
|
||||||
|
* adding a printer should not start with someone walking to a machine to
|
||||||
|
* read its IP address off the screen.
|
||||||
|
*
|
||||||
|
* What a candidate cannot carry is a credential, and no amount of probing
|
||||||
|
* will change that: a Bambu access code lives on the printer's display and
|
||||||
|
* a PrusaLink API key in its web UI. So this shortens the job rather than
|
||||||
|
* removing it - the operator still types one secret, instead of a secret
|
||||||
|
* and an address and a serial number.
|
||||||
|
*
|
||||||
|
* `null` for `discovery` is the normal answer, and means the gateway has
|
||||||
|
* never reported a scan - almost always because discovery is off, which is
|
||||||
|
* its default. That is not the same as a report with an empty `printers`,
|
||||||
|
* which means the gateway looked and found nothing.
|
||||||
|
*
|
||||||
|
* `viewer`, like the list above and for the same reason: this reports what
|
||||||
|
* hardware exists. Creating the row still needs `owner`.
|
||||||
|
*/
|
||||||
|
.get(
|
||||||
|
"/:id/discovered-printers",
|
||||||
|
async ({ user, params }) => {
|
||||||
|
await requireFarm(user.id, params.id, "viewer");
|
||||||
|
const discovery = getDiscoveredPrinters(params.id);
|
||||||
|
const gatewayOnline = isGatewayOnline(params.id);
|
||||||
|
if (!discovery) {
|
||||||
|
return { farmId: params.id, gatewayOnline, discovery: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-check against the rows this farm already has, so the console
|
||||||
|
// can grey out a candidate instead of offering a duplicate. The
|
||||||
|
// gateway filters its own fleet file before reporting, but it knows
|
||||||
|
// nothing about printer rows registered in the cloud and never
|
||||||
|
// claimed, which is exactly the case that produces a confusing
|
||||||
|
// "add" button for a printer the operator already added.
|
||||||
|
const existing = await db
|
||||||
|
.select({
|
||||||
|
id: printers.id,
|
||||||
|
serialNumber: printers.serialNumber,
|
||||||
|
lanAddress: printers.lanAddress,
|
||||||
|
})
|
||||||
|
.from(printers)
|
||||||
|
.where(eq(printers.farmId, params.id));
|
||||||
|
|
||||||
|
const bySerial = new Map<string, string>();
|
||||||
|
const byAddress = new Map<string, string>();
|
||||||
|
for (const row of existing) {
|
||||||
|
if (row.serialNumber) bySerial.set(row.serialNumber.toLowerCase(), row.id);
|
||||||
|
if (row.lanAddress) byAddress.set(row.lanAddress.toLowerCase(), row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
farmId: params.id,
|
||||||
|
gatewayOnline,
|
||||||
|
discovery: {
|
||||||
|
scannedAt: discovery.scannedAt,
|
||||||
|
reportedAt: discovery.reportedAt,
|
||||||
|
gatewayId: discovery.gatewayId,
|
||||||
|
printers: discovery.printers.map((candidate) => {
|
||||||
|
const bySerialMatch = candidate.serial
|
||||||
|
? bySerial.get(candidate.serial.toLowerCase())
|
||||||
|
: undefined;
|
||||||
|
const byAddressMatch = byAddress.get(candidate.address.toLowerCase());
|
||||||
|
return {
|
||||||
|
...candidate,
|
||||||
|
/**
|
||||||
|
* The printer row this candidate looks like, or null.
|
||||||
|
*
|
||||||
|
* `matchedOn` says how sure that is, and the two are not
|
||||||
|
* equally sure. A serial number identifies a machine. An
|
||||||
|
* address identifies a DHCP lease, so an address match can
|
||||||
|
* be the printer that held that address last month - which
|
||||||
|
* is why the basis is reported instead of just a boolean,
|
||||||
|
* and why the console should warn on `address` rather than
|
||||||
|
* refuse to add.
|
||||||
|
*/
|
||||||
|
existingPrinterId: bySerialMatch ?? byAddressMatch ?? null,
|
||||||
|
matchedOn: bySerialMatch ? "serial" : byAddressMatch ? "address" : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: IdParams,
|
||||||
|
detail: {
|
||||||
|
tags: ["farms"],
|
||||||
|
summary: "Printers the gateway found on its network that are not in its fleet",
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Elysia } from "elysia";
|
|||||||
import jwt from "@elysiajs/jwt";
|
import jwt from "@elysiajs/jwt";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
type DiscoveredPrinter,
|
||||||
EdgeClientMessageSchema,
|
EdgeClientMessageSchema,
|
||||||
type EdgeServerMessage,
|
type EdgeServerMessage,
|
||||||
type PrinterCommand,
|
type PrinterCommand,
|
||||||
@@ -192,6 +193,107 @@ export function listUnclaimedPrinters(farmId: string): UnclaimedPrinter[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What each farm's gateway last reported finding on its own network and USB
|
||||||
|
* ports - printers that are *not* in its fleet file, so it cannot drive them.
|
||||||
|
*
|
||||||
|
* Distinct from `unclaimedByFarm` above, and the distinction is the whole
|
||||||
|
* point. An unclaimed printer is already in the gateway's `printers.toml`:
|
||||||
|
* someone wrote its address and its credential down, the gateway is talking
|
||||||
|
* to it, and all that is missing is a printer row. A discovered one is a
|
||||||
|
* machine the gateway merely *saw*. Nobody has 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.
|
||||||
|
*
|
||||||
|
* One entry per farm rather than a map keyed by printer, because a report is
|
||||||
|
* a whole scan result and replaces the last one. Discovery has no stable key
|
||||||
|
* to merge on anyway: a Bambu printer has a serial, a Klipper host has only
|
||||||
|
* the address DHCP gave it this week, and merging on that would accumulate
|
||||||
|
* one phantom candidate per lease.
|
||||||
|
*
|
||||||
|
* In memory for the same reason as `unclaimedByFarm`: live gateway state,
|
||||||
|
* worthless once stale, and rebuilt by the next scan after a reconnect.
|
||||||
|
* Deliberately *not* cleared when the gateway disconnects - the last known
|
||||||
|
* list plus `gatewayOnline` and `scannedAt` lets a caller say "found 20
|
||||||
|
* minutes ago, gateway offline now", which is more use to an operator than
|
||||||
|
* an empty list that looks like "nothing there".
|
||||||
|
*/
|
||||||
|
const discoveredByFarm = new Map<string, FarmDiscovery>();
|
||||||
|
|
||||||
|
export interface FarmDiscovery {
|
||||||
|
printers: DiscoveredPrinterReport[];
|
||||||
|
/** When the gateway finished the scan, if it said. */
|
||||||
|
scannedAt: string | null;
|
||||||
|
/** When this backend received the report, which it always knows. */
|
||||||
|
reportedAt: string;
|
||||||
|
gatewayId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* camelCase, unlike the wire message it comes from: this is shaped for the
|
||||||
|
* HTTP route that serves it, and the rest of the backend's REST surface is
|
||||||
|
* camelCase. The conversion happens once, here, rather than in the route.
|
||||||
|
*/
|
||||||
|
export interface DiscoveredPrinterReport {
|
||||||
|
vendor: string;
|
||||||
|
address: string;
|
||||||
|
serial: string | null;
|
||||||
|
model: string | null;
|
||||||
|
name: string | null;
|
||||||
|
detail: string | null;
|
||||||
|
/** A vendor this could equally be; the operator settles it. */
|
||||||
|
ambiguousWith: string | null;
|
||||||
|
source: string;
|
||||||
|
/** False when the gateway found it but has no working client for it. */
|
||||||
|
driverReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteDiscovered(
|
||||||
|
farmId: string,
|
||||||
|
gatewayId: string,
|
||||||
|
printers: readonly DiscoveredPrinter[],
|
||||||
|
scannedAt: string | null,
|
||||||
|
) {
|
||||||
|
discoveredByFarm.set(farmId, {
|
||||||
|
// Every string in here was chosen by a device on the customer's network,
|
||||||
|
// so it is display text and nothing more. It is never used to build a
|
||||||
|
// query, a path or a connection, and the schema caps its length - see
|
||||||
|
// `DiscoveredPrinterSchema`. Validation already happened: `body:
|
||||||
|
// EdgeClientMessageSchema` on the route below rejects the frame
|
||||||
|
// otherwise, which is why this can map without re-checking.
|
||||||
|
printers: printers.map((printer) => ({
|
||||||
|
vendor: printer.vendor,
|
||||||
|
address: printer.address,
|
||||||
|
// `?? null` collapses the wire's "absent or null" into one shape, the
|
||||||
|
// same reason the `frame_batch` handler does it below.
|
||||||
|
serial: printer.serial ?? null,
|
||||||
|
model: printer.model ?? null,
|
||||||
|
name: printer.name ?? null,
|
||||||
|
detail: printer.detail ?? null,
|
||||||
|
ambiguousWith: printer.ambiguous_with ?? null,
|
||||||
|
source: printer.source,
|
||||||
|
driverReady: printer.driver_ready,
|
||||||
|
})),
|
||||||
|
scannedAt: scannedAt ?? null,
|
||||||
|
reportedAt: new Date().toISOString(),
|
||||||
|
gatewayId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last scan this farm's gateway reported, or null if it has never
|
||||||
|
* reported one.
|
||||||
|
*
|
||||||
|
* Null is the usual answer, because discovery is off by default in the
|
||||||
|
* gateway's own config - probing a customer's network is something they have
|
||||||
|
* to switch on. A caller should treat null as "not enabled or not scanned
|
||||||
|
* yet", which is different from a report whose `printers` is empty: that one
|
||||||
|
* means the gateway looked and found nothing.
|
||||||
|
*/
|
||||||
|
export function getDiscoveredPrinters(farmId: string): FarmDiscovery | null {
|
||||||
|
return discoveredByFarm.get(farmId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** How long to wait for a gateway to answer a command before giving up. */
|
/** How long to wait for a gateway to answer a command before giving up. */
|
||||||
const COMMAND_TIMEOUT_MS = 10_000;
|
const COMMAND_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
@@ -646,6 +748,25 @@ export const edgeWsRoute = new Elysia()
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "discovered_printers": {
|
||||||
|
if (!conn.farmId) return;
|
||||||
|
// No gateway id until `hello`, and the handshake timer closes a
|
||||||
|
// socket that never sends one, so this cannot be reported by an
|
||||||
|
// anonymous socket for long. It is recorded rather than demanded
|
||||||
|
// because a scan result is still true without it.
|
||||||
|
noteDiscovered(
|
||||||
|
conn.farmId,
|
||||||
|
conn.gatewayId,
|
||||||
|
message.printers,
|
||||||
|
message.scanned_at ?? null,
|
||||||
|
);
|
||||||
|
console.info(
|
||||||
|
`[edge] gateway ${conn.gatewayId} discovered ${message.printers.length} ` +
|
||||||
|
`printer(s) not in its fleet (farm ${conn.farmId})`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "frame_batch": {
|
case "frame_batch": {
|
||||||
if (!conn.farmId) return;
|
if (!conn.farmId) return;
|
||||||
|
|
||||||
|
|||||||
@@ -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", gateway_id: "g" })).toBe(false);
|
||||||
expect(Value.Check(EdgeClientMessageSchema, { type: "hello", farm_id: "f" })).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", () => {
|
describe("backend -> gateway frames", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user