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 { 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<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 { 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<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. */
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user