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' and receive 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, IP, battery voltage, poll count, last seen) and approves or blocks it.
  3. 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).
  4. 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 /eink/displaysadminListing with location names
POST /eink/display/:idadminUpdate name/location/size/rotation/validity/layout
POST /eink/display/:id/stateadminpending / approved / blocked
DELETE /eink/display/:idadminRemove (device re-registers as pending if it keeps polling)
GET /eink/sensorsadminSensor beacons + latest readings for the designer picker

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 & state: mac (unique), 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

The designer layout (stored in layout) uses numeric fields and adds a virtual sensor element (sensor_mac, metric, label, unit, decimals, position, font, color). 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).

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
web/src/admin/pages/eink-displays.tsListing page (approve/block/edit/delete)
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.