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",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user