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>
137 lines
5.1 KiB
TypeScript
137 lines
5.1 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|