Speak the gateway's actual protocol on /ws/edge/v1, and persist telemetry
This route and continuum-proxy had drifted into entirely different protocols. It expected an `auth` handshake the gateway never sends, one printer per telemetry message where the gateway sends a batch, UUID printer ids a gateway has no way to know, and camelCase field names against the gateway's snake_case. It had no way to send a command to a printer at all. Telemetry was acknowledged and thrown away - the handler said as much. It now implements schemas/continuum/v1/edge/ via its TypeBox mirror in @continuum/ts-types, so both ends mirror one written contract instead of each other's assumptions. The missing link was identity: a gateway knows its printers as "p1p", this database knows them as UUIDs, and nothing mapped between them. printers.gateway_printer_id is that mapping, unique per farm since two farms may each have a printer called "p1p". An unrecognised id is logged and skipped rather than inserted - a gateway should not be able to create printer records by mentioning them, or a typo in someone's printers.toml becomes a permanent phantom printer. Telemetry now lands in `printers` as a latest-value snapshot, extending the pattern status/nozzle_temp_c/bed_temp_c already used: target temperatures, progress, layers, remaining minutes, job name, active alert count. Also vendor_state alongside the narrowed status enum, because a state we do not recognise yet would otherwise be flattened and lost - and an unrecognised state deliberately leaves status untouched rather than falling back to "idle", since idle is what the scheduler reads as safe to send work to. Commands can now go down: sendPrinterCommand() takes our own printer id, translates it on the way out, and waits for the gateway's result, with a 10s timeout and a registry keyed by farm. Pending commands are failed immediately when a gateway disconnects rather than making every caller wait out the timeout for a socket that is already gone. POST /printers/:id/commands exposes it, validated against the same shared schema the gateway accepts, so this route cannot offer something no gateway can execute - which notably means there is still no way to start a print. tests/edge_protocol.test.ts is the contract test, run against real frames captured from a live gateway talking to a real P1P and H2C rather than hand-written examples - hand-written ones agree with whoever wrote them, which is how this drifted in the first place. The second fixture is the same capture from before the gateway was taught to omit empty fields, kept deliberately: the validator must keep accepting explicit nulls. Not yet applied to a database: this adds columns but no migration, and the repo has none committed. Run db:generate (or push) before the next deploy.
This commit is contained in:
+76
-19
@@ -1,4 +1,4 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real } from "drizzle-orm/pg-core";
|
||||
import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real, integer, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { farms } from "./farms";
|
||||
|
||||
export const printerVendorEnum = pgEnum("printer_vendor", ["bambu", "prusa", "klipper"]);
|
||||
@@ -11,24 +11,81 @@ export const printerStatusEnum = pgEnum("printer_status", [
|
||||
"maintenance",
|
||||
]);
|
||||
|
||||
export const printers = pgTable("printers", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
farmId: uuid("farm_id")
|
||||
.notNull()
|
||||
.references(() => farms.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
vendor: printerVendorEnum("vendor").notNull(),
|
||||
status: printerStatusEnum("status").notNull().default("offline"),
|
||||
lanAddress: text("lan_address"),
|
||||
serialNumber: text("serial_number"),
|
||||
accessCode: text("access_code"),
|
||||
nozzleTempC: real("nozzle_temp_c"),
|
||||
bedTempC: real("bed_temp_c"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
||||
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
export const printers = pgTable(
|
||||
"printers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
farmId: uuid("farm_id")
|
||||
.notNull()
|
||||
.references(() => farms.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
vendor: printerVendorEnum("vendor").notNull(),
|
||||
status: printerStatusEnum("status").notNull().default("offline"),
|
||||
lanAddress: text("lan_address"),
|
||||
serialNumber: text("serial_number"),
|
||||
accessCode: text("access_code"),
|
||||
|
||||
/**
|
||||
* The id this printer has in its gateway's own `printers.toml`, e.g.
|
||||
* "p1p". The missing link between the two halves of the system: a
|
||||
* gateway identifies its printers by this and knows nothing about
|
||||
* `id` above, so every telemetry row and every outbound command is
|
||||
* translated through it. Nullable because a printer can be registered
|
||||
* in the cloud before any gateway claims it.
|
||||
*
|
||||
* Not globally unique - two farms may each have a printer called
|
||||
* "p1p" - hence the per-farm unique index below rather than
|
||||
* `.unique()`.
|
||||
*/
|
||||
gatewayPrinterId: text("gateway_printer_id"),
|
||||
|
||||
/**
|
||||
* Latest-snapshot telemetry, written by the /ws/edge/v1 route. This
|
||||
* table deliberately keeps only the *current* value of each of these
|
||||
* rather than a history: a time-series of them is a separate concern
|
||||
* with very different storage characteristics, and nothing needs it
|
||||
* yet. `status` and the two measured temperatures were already part of
|
||||
* this pattern; the rest extend it.
|
||||
*/
|
||||
nozzleTempC: real("nozzle_temp_c"),
|
||||
nozzleTargetTempC: real("nozzle_target_temp_c"),
|
||||
bedTempC: real("bed_temp_c"),
|
||||
bedTargetTempC: real("bed_target_temp_c"),
|
||||
progressPercent: integer("progress_percent"),
|
||||
currentLayer: integer("current_layer"),
|
||||
totalLayers: integer("total_layers"),
|
||||
remainingMinutes: integer("remaining_minutes"),
|
||||
currentJobName: text("current_job_name"),
|
||||
/**
|
||||
* How many Health-Management-System entries the printer reports as
|
||||
* active. A count, not decoded messages - that needs Bambu's HMS
|
||||
* tables, which Continuum does not have.
|
||||
*/
|
||||
activeAlerts: integer("active_alerts"),
|
||||
/**
|
||||
* The printer's own state string, kept alongside the narrowed
|
||||
* `status` enum. Worth storing both: the enum is what the UI filters
|
||||
* and sorts on, but a vendor state Continuum does not recognise yet
|
||||
* would otherwise be flattened into "idle" and lost. If this reads
|
||||
* something unexpected, that is the signal to widen the mapping.
|
||||
*/
|
||||
vendorState: text("vendor_state"),
|
||||
/** Vendor fault code; non-zero means the printer is reporting a fault. */
|
||||
vendorErrorCode: integer("vendor_error_code"),
|
||||
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
||||
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
// What the edge route looks printers up by, on every telemetry batch.
|
||||
gatewayPrinterIdx: uniqueIndex("printers_farm_gateway_printer_id_idx").on(
|
||||
table.farmId,
|
||||
table.gatewayPrinterId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Printer = typeof printers.$inferSelect;
|
||||
export type NewPrinter = typeof printers.$inferInsert;
|
||||
|
||||
Reference in New Issue
Block a user