Files
mars3142 cadc280761 Scaffold Fastify + Drizzle API with pick-by-light
Layered: routes -> services -> repositories -> db, over PostgreSQL 18 with
split roles (findr_migrator for DDL, findr_app for DML). Keycloak JWT auth
with per-endpoint scope guards. Drizzle migrations run with the DDL role at
container start.

Pick-by-light: one ESP32 controller drives one WS2812 chain through several
boxes; each box owns a contiguous LED slice. MQTT contract in
src/lib/mqtt-topics.ts / docs/mqtt.md. /v1/pick controls light/idle/off and
/v1/boxes + /v1/controllers manage the strip mapping.

Reference resource /v1/parts wired end to end. Vitest, Biome, multi-stage
Dockerfile, local compose.yml with Postgres.

Signed-off-by: Peter Siegmund <mars3142@noreply.mars3142.dev>
2026-09-03 01:07:35 +02:00

137 lines
5.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The MQTT contract between findr-api and the pick-by-light hardware.
*
* A **controller** is one ESP32-S3/C6 driving a single WS2812 chain that can run
* through several **boxes**. A box owns a contiguous slice of that chain
* (`ledOffset` … `ledOffset + ledCount`). A **cell** is a compartment ("D4").
*
* findr-api owns all topology: it publishes each box's config (retained) and
* resolves cells to LED indices, so the firmware only has to know its own
* `espId` and chain length, then paint `chain[box.ledOffset + ledIndex]`.
*
* A box is always in one effect:
* idle rainbow animation over the box's slice (resting state)
* pick only the listed bins lit (take = orange, put = green); rest dark
* off slice dark
* After a `pick` with `seconds`, the box returns to `idle` by itself.
*
* Topics (prefix defaults to "findr"; `<n>` = box number, `<esp>` = controller espId):
*
* findr/box/<n>/config api → fw QoS1, RETAINED BoxConfig
* findr/box/<n>/cmd/light api → fw QoS1 LightCommand
* findr/box/<n>/state/light fw → api QoS1, RETAINED LightState
* findr/box/<n>/evt/button fw → api QoS1 ButtonEvent
* findr/controller/<esp>/state/online fw → * QoS1, RETAINED, LWT "online" | "offline"
* findr/controller/<esp>/state/info fw → api QoS1, RETAINED ControllerInfo
* findr/controller/<esp>/cmd/identify api → fw QoS1 { seconds?: number }
*/
export type BinMode = "take" | "put";
export type BoxEffect = "idle" | "pick" | "off";
export type BoxWiring = "progressive" | "serpentine";
export type OnlineState = "online" | "offline";
/** One bin to light. `ledIndex` is resolved by findr-api (within-box, 0-based). */
export interface BinTarget {
cell: string;
ledIndex: number;
mode: BinMode;
/** Override colour (hex). The firmware picks a default from `mode` otherwise. */
color?: string;
blink?: boolean;
}
/** Retained on `box/<n>/config` — tells the firmware where the box lives. */
export interface BoxConfig {
boxNumber: number;
controllerEspId: string | null;
ledOffset: number;
ledCount: number;
columns: number;
rows: number;
wiring: BoxWiring;
}
/** Published to `box/<n>/cmd/light`. */
export interface LightCommand {
effect: BoxEffect;
/** Required when effect === "pick"; the complete set of lit bins. */
bins?: BinTarget[];
/** For "pick": auto-return to "idle" after N seconds. 0 / omitted = stay. */
seconds?: number;
/** Echoed back in LightState.requestId. */
requestId?: string;
}
/** Retained on `box/<n>/state/light`. */
export interface LightState {
effect: BoxEffect;
lit: Array<{ cell: string; mode: BinMode }>;
requestId?: string;
ts: string;
}
export interface ButtonEvent {
cell: string;
action: "confirm" | "cancel";
ts: string;
}
export interface ControllerInfo {
espId: string;
firmware?: string;
ip?: string;
ledCount?: number;
uptimeS?: number;
}
// ── topic builders ───────────────────────────────────────────────────────────
export function topics(prefix = "findr") {
const box = (n: number | "+") => `${prefix}/box/${n}`;
const ctrl = (esp: string) => `${prefix}/controller/${esp}`;
return {
prefix,
boxConfig: (n: number) => `${box(n)}/config`,
cmdLight: (n: number) => `${box(n)}/cmd/light`,
stateLight: (n: number | "+") => `${box(n)}/state/light`,
evtButton: (n: number | "+") => `${box(n)}/evt/button`,
ctrlOnline: (esp: string) => `${ctrl(esp)}/state/online`,
ctrlInfo: (esp: string) => `${ctrl(esp)}/state/info`,
ctrlIdentify: (esp: string) => `${ctrl(esp)}/cmd/identify`,
/** What findr-api subscribes to. */
allBoxState: () => `${prefix}/box/+/state/+`,
allBoxEvents: () => `${prefix}/box/+/evt/+`,
allControllerState: () => `${prefix}/controller/+/state/+`,
};
}
/** Pull the box number out of a `findr/box/<n>/...` topic, or null. */
export function boxNumberFromTopic(topic: string, prefix = "findr"): number | null {
const m = topic.match(new RegExp(`^${prefix}/box/(\\d+)/`));
return m?.[1] ? Number.parseInt(m[1], 10) : null;
}
/** Pull the controller espId out of a `findr/controller/<esp>/...` topic, or null. */
export function controllerEspIdFromTopic(topic: string, prefix = "findr"): string | null {
const m = topic.match(new RegExp(`^${prefix}/controller/([^/]+)/`));
return m?.[1] ?? null;
}
// ── command builders ─────────────────────────────────────────────────────────
export function pickCommand(
bins: BinTarget[],
opts: { seconds?: number; requestId?: string } = {},
): LightCommand {
return { effect: "pick", bins, seconds: opts.seconds, requestId: opts.requestId };
}
export function idleCommand(requestId?: string): LightCommand {
return { effect: "idle", requestId };
}
export function offCommand(requestId?: string): LightCommand {
return { effect: "off", requestId };
}