Skip to content

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

  1. Auto-registration — a display is configured with the server URL and starts polling GET /eink/display?voltage=<v>&mac=<mac>. Unknown MACs are inserted into eink_displays with state = 'pending', receive a LEGACY-{mac} asset_uid plus a devices / device_assignments row, and are served a neutral "Pending verification" screen. Nothing about the system is revealed to unapproved devices.
  2. 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.
  3. Identity — assigning a coded home location remints EK-LOCATION_PATH-SEQ. Hardware swaps use POST /api/v1/eink/display/:id/replace-device (UID stays stable; eink_displays.mac updates). Poll wire contract stays GET /eink/display?mac=.
  4. 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).
  5. 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:

  1. Renders the layout (pending screen, or designer layout + current sensor values).
  2. Hashes {rotation, validity, objects}.
  3. If the hash differs from rendered_hash stored on the row, bumps version = version % 99 + 1 and 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

EndpointAuthPurpose
GET|POST /eink/display (also with trailing slash)noneDevice poll: tracking + layout JSON (Windows-1250)
GET /api/v1/eink/displaysadminListing with location names
POST /api/v1/eink/display/:idadminUpdate name/location/size/rotation/validity/layout (remints EK-* when location becomes coded)
POST /api/v1/eink/display/:id/replace-deviceadminSwap device MAC; asset_uid unchanged
POST /api/v1/eink/display/:id/stateadminpending / approved / blocked
DELETE /api/v1/eink/display/:idadminRemove (device re-registers as pending if it keeps polling)
GET /api/v1/eink/sensorsadminSensor beacons + asset_uid + latest readings for the designer picker
GET|POST /api/v1/eink/layout-templatesadminList or create named reusable layouts
POST|DELETE /api/v1/eink/layout-template/:idadminUpdate/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-* or LEGACY-*), 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_assignments rows use eink_id (exactly one of asset_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

FilePurpose
lib/src/eink.tsShared 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.tsDevice poll handler, tracking, version bumping, admin endpoints
server/src/eink_identity.tsAsset UID mint, device assignment, replace-device
web/src/admin/pages/eink-displays.tsListing page (approve/block/edit/delete/replace)
web/src/admin/pages/eink-designer.tsCanvas layout editor
server/tests/eink.test.tsUnit tests (encoder, render)
server/tests/api/eink.test.tsEnd-to-end device + admin API tests
web/tests/e2e/eink-displays.spec.tsPlaywright 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.