Appearance
E-Ink Displays
SafeCall serves wifi e-ink displays (door signs, room status panels) that poll an unauthenticated endpoint and draw a layout designed by an admin in the web UI. The device-side protocol is documented in the E-Ink Display Spec.
Lifecycle
- Auto-registration — a display is configured with the server URL and starts polling
GET /eink/display?voltage=<v>&mac=<mac>. Unknown MACs are inserted intoeink_displayswithstate = 'pending', receive aLEGACY-{mac}asset_uidplus adevices/device_assignmentsrow, and are served a neutral "Pending verification" screen. Nothing about the system is revealed to unapproved devices. - Approval — an admin reviews the device under Devices → E-Ink Displays (MAC, Asset UID, IP, battery voltage, poll count, last seen) and approves or blocks it.
- Identity — assigning a coded home location remints
EK-LOCATION_PATH-SEQ. Hardware swaps usePOST /api/v1/eink/display/:id/replace-device(UID stays stable;eink_displays.macupdates). Poll wire contract staysGET /eink/display?mac=. - Design — the admin designs the layout in the visual editor (canvas preview, all 8 drawing primitives, 12 fonts, 4 colors, plus sensor value elements bound to sensor beacons, filterable by location).
- Serving — on each poll the server renders the designer layout, substitutes live sensor readings, and responds with the device JSON encoded as Windows-1250.
Change detection (version bumping)
The firmware only redraws when the layout version changes (1–99, wraps to 1). On every poll the server:
- Renders the layout (pending screen, or designer layout + current sensor values).
- Hashes
{rotation, validity, objects}. - If the hash differs from
rendered_hashstored on the row, bumpsversion = version % 99 + 1and persists both.
Admin edits and sensor reading changes therefore propagate automatically on the next poll. Sensor values are rounded to the per-element decimals setting, which keeps tiny fluctuations from causing constant redraws.
HTTP endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
GET|POST /eink/display (also with trailing slash) | none | Device poll: tracking + layout JSON (Windows-1250) |
GET /api/v1/eink/displays | admin | Listing with location names |
POST /api/v1/eink/display/:id | admin | Update name/location/size/rotation/validity/layout (remints EK-* when location becomes coded) |
POST /api/v1/eink/display/:id/replace-device | admin | Swap device MAC; asset_uid unchanged |
POST /api/v1/eink/display/:id/state | admin | pending / approved / blocked |
DELETE /api/v1/eink/display/:id | admin | Remove (device re-registers as pending if it keeps polling) |
GET /api/v1/eink/sensors | admin | Sensor beacons + asset_uid + latest readings for the designer picker |
GET|POST /api/v1/eink/layout-templates | admin | List or create named reusable layouts |
POST|DELETE /api/v1/eink/layout-template/:id | admin | Update/rename or delete a saved layout |
Abuse protection: MACs must be valid 12-hex-digit values, and auto-registration stops once 100 rows are pending (the neutral screen is still served, nothing is persisted).
Data model
eink_displays (migration 037_create_eink_displays.ts, identity columns from 087):
- Identity & state:
mac(unique, denormalized current device MAC),asset_uid(EK-*orLEGACY-*),name,state,location_id - Display settings:
width,height(default 800x480),rotation(0–3),validity(poll seconds) - Layout:
layout(designer JSON),version(1–99),rendered_hash - Tracking:
last_voltage,last_ip,poll_count,first_seen,last_seen - Assignments: open
device_assignmentsrows useeink_id(exactly one ofasset_id|gateway_id|eink_id)
The designer layout (stored in layout) uses numeric fields and adds a virtual sensor element (sensor_uid = the sensor asset UID, metric, label, unit, decimals, position, font, color). Legacy JSON may still say sensor_mac; parse copies that onto sensor_uid. Migration 103 rewrites stored layouts to the current beacons.asset_uid of that MAC. At serve time eink_layout_render() materializes sensor elements into text objects using the latest sensor_data reading per metric (1 humidity, 2 temperature, 3 battery), looked up via asset_uid (MAC leftover fallback). After Replace device, a cell stays on the place, not the radio. Firmware poll remains GET /eink/display?mac=.
Saved layouts
eink_layout_templates stores named copies of complete manual or Status Grid layouts. A saved layout is distinct from the Status Grid design mode: it is a reusable library item that can be loaded onto another display with the same canvas size and rotation. Loading clones the JSON into the editor and asks the admin to map every referenced sensor to a sensor on the target display; the saved source is never mutated. The normal Save Layout action then persists the mapped copy to the target display.
Key files
| File | Purpose |
|---|---|
lib/src/eink.ts | Shared module: designer/device schemas, eink_layout_render(), font/color tables, Windows-1250 encoder, pending screen. Used by server and web so the editor preview matches the device pixel-for-pixel |
server/src/eink.ts | Device poll handler, tracking, version bumping, admin endpoints |
server/src/eink_identity.ts | Asset UID mint, device assignment, replace-device |
web/src/admin/pages/eink-displays.ts | Listing page (approve/block/edit/delete/replace) |
web/src/admin/pages/eink-designer.ts | Canvas layout editor |
server/tests/eink.test.ts | Unit tests (encoder, render) |
server/tests/api/eink.test.ts | End-to-end device + admin API tests |
web/tests/e2e/eink-displays.spec.ts | Playwright UI tests |
Encoding
Bun has no Windows-1250 encoder, so eink_encode_windows1250() maps the 0x80–0xFF range by lookup table (full Czech diacritics support) and replaces unmappable characters with ?. JSON structural characters are ASCII, so the encoded bytes remain valid JSON.