diff --git a/src/routes/farms.ts b/src/routes/farms.ts index 336644a..828b76b 100644 --- a/src/routes/farms.ts +++ b/src/routes/farms.ts @@ -2,11 +2,11 @@ import { Elysia, t } from "elysia"; import { and, desc, eq } from "drizzle-orm"; import { NotFoundError } from "@continuum/ts-core"; import { db } from "../db"; -import { farms } from "../db/schema"; +import { farms, printers } from "../db/schema"; import { farmProjection } from "../db/projections"; import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth"; 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" }) }); @@ -210,4 +210,101 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) 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(); + const byAddress = new Map(); + 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", + }, + }, ); diff --git a/src/routes/ws/edge.ts b/src/routes/ws/edge.ts index 0145363..eaad3f4 100644 --- a/src/routes/ws/edge.ts +++ b/src/routes/ws/edge.ts @@ -2,6 +2,7 @@ import { Elysia } from "elysia"; import jwt from "@elysiajs/jwt"; import { and, eq } from "drizzle-orm"; import { + type DiscoveredPrinter, EdgeClientMessageSchema, type EdgeServerMessage, 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(); + +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. */ const COMMAND_TIMEOUT_MS = 10_000; @@ -646,6 +748,25 @@ export const edgeWsRoute = new Elysia() 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": { if (!conn.farmId) return; diff --git a/tests/edge_protocol.test.ts b/tests/edge_protocol.test.ts index 367cd23..685849c 100644 --- a/tests/edge_protocol.test.ts +++ b/tests/edge_protocol.test.ts @@ -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", () => {