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>
3.7 KiB
MQTT contract — pick-by-light
Broker: Mosquitto 2.1 on mars3142-01 (mosquitto:1883 internal,
mqtts://mqtt.mars3142.dev:8883 public). allow_anonymous false, password +
ACL file.
TypeScript source of truth: src/lib/mqtt-topics.ts.
Model
- Controller — one ESP32-S3/C6 driving a single WS2812 chain. Identified by
espId. Can serve several boxes. - Box — a sortiment box. Owns a contiguous slice of its controller's chain:
[ledOffset, ledOffset + ledCount). Has a grid (columns × rows,wiring). - Cell — a compartment ("D4"). Its LED =
ledOffset + withinBoxIndex, wherewithinBoxIndexislocations.ledIndexif set, else derived from the grid.
findr-api owns all topology. It publishes each box's config (retained) and
resolves cells → LED indices, so the firmware only needs its own espId and
chain length, then paints chain[ledOffset + ledIndex].
Effects
| effect | box's slice |
|---|---|
idle |
rainbow animation (resting state) |
pick |
only the listed bins lit (take=orange, put=green) |
off |
dark |
After a pick with seconds, the box returns to idle on its own.
Topics
<n> = box number, <esp> = controller espId, prefix defaults to findr.
| Topic | Dir | Retain | Payload |
|---|---|---|---|
findr/box/<n>/config |
api → fw | yes | BoxConfig |
findr/box/<n>/cmd/light |
api → fw | no | LightCommand |
findr/box/<n>/state/light |
fw → api | yes | LightState |
findr/box/<n>/evt/button |
fw → api | no | ButtonEvent |
findr/controller/<esp>/state/online |
fw → * | yes | online/offline (LWT) |
findr/controller/<esp>/state/info |
fw → api | yes | ControllerInfo |
findr/controller/<esp>/cmd/identify |
api → fw | no | { seconds } |
BoxConfig (retained)
{ "boxNumber": 2, "controllerEspId": "a1b2c3", "ledOffset": 40,
"ledCount": 40, "columns": 8, "rows": 5, "wiring": "serpentine" }
The firmware keeps configs whose controllerEspId matches its own espId.
LightCommand
{ "effect": "pick",
"bins": [ { "cell": "D4", "ledIndex": 27, "mode": "take" } ],
"seconds": 30, "requestId": "uuid" }
{ "effect": "idle" } / { "effect": "off" } carry no bins.
LightState (retained)
{ "effect": "pick", "lit": [ { "cell": "D4", "mode": "take" } ],
"requestId": "uuid", "ts": "2026-09-02T21:15:00Z" }
Broker users & ACL
Two users. Add to /opt/docker/mosquitto/config/passwd:
docker exec mosquitto mosquitto_passwd -b /mosquitto/config/passwd findr-api '<api-pw>'
docker exec mosquitto mosquitto_passwd -b /mosquitto/config/passwd findr-controller '<ctrl-pw>'
Append to /opt/docker/mosquitto/config/acl.txt:
user findr-api
topic write findr/box/+/config
topic write findr/box/+/cmd/#
topic write findr/controller/+/cmd/#
topic read findr/box/+/state/#
topic read findr/box/+/evt/#
topic read findr/controller/+/state/#
user findr-controller
topic read findr/box/+/config
topic read findr/box/+/cmd/#
topic read findr/controller/+/cmd/#
topic write findr/box/+/state/#
topic write findr/box/+/evt/#
topic write findr/controller/+/state/#
Reload without dropping connections: docker kill -s HUP mosquitto.
findr-controller is shared by every ESP32 for now. Once box provisioning is in
place, switch to per-controller users and scope with %u, e.g.
pattern write findr/controller/%u/state/#.