A watchful light for a 32×8 pixel display. It polls a JSON endpoint, rotates through the screens it returns, and lights an alert when it can no longer reach fresh data.
The name Watchlight captures the device's one job and the rule it never breaks: keep watch over the data and, the moment it can't see anything fresh, light an alert rather than show a stale number wearing a fresh face.
Watchlight is content-agnostic: it knows nothing about the business it shows. Every server screen arrives fully formed — text, color, and an 8×8 icon bitmap — so adding, removing, or restyling one is a server change, never a reflash. The firmware's only opinions are how to draw and when to admit it's offline.
The one exception is a few local screens for data no server could provide — the on-board clock, the temperature/humidity sensor, and a low-battery warning. They ride in the same rotation (see Local screens).
Target hardware is the Ulanzi TC001 (ESP32 + WS2812B matrix), but any ESP32
board with a compatible 32×8 matrix works by adjusting the pins in main.cpp.
Watchlight expects GET {API_URL} (sent with an x-device-token header) to return:
- Payload format — canonical transport, fields, encoding, and freshness behavior.
- JSON Schema — machine-readable Draft 2020-12 validation.
- Example payload — complete schema-valid JSON.
At a glance, the response looks like this (the icon is abbreviated here):
{
"ts": 1784027753,
"staleAfter": 1800,
"screens": [
{ "id": "users", "text": "258k", "color": "#3b82f6", "icon": "<8×8 #rrggbb matrix>" }
]
}| field | meaning |
|---|---|
ts |
epoch seconds when the numbers were read (for the server; the firmware tracks its own last-success clock) |
staleAfter |
seconds of no successful fetch before the offline glyph replaces the screens |
screens[].id |
stable screen identifier; currently metadata for the firmware |
screens[].text |
short string, pre-formatted for the display ("258k", not "258482") |
screens[].color |
#rrggbb text color |
screens[].icon |
8×8 matrix of web colors: icon[y][x] is #rrggbb; #000000 is an off pixel. |
The rotation interval is device-local, not part of the payload. Set
SCREEN_ROTATION_SECONDS in src/config.h; the default is 8 seconds.
Polling is device-local too. While the payload is fresh, Watchlight polls every
15 minutes; once it is stale (or before the first successful fetch), it retries
every minute. Configure both intervals with POLL_INTERVAL_FRESH_SECONDS and
POLL_INTERVAL_STALE_SECONDS in src/config.h.
The device renders the icon on the left 8×8 and the text — a small pixel font — centered in the remaining 24px. Icons may use full RGB: gradients and anti-aliased edges render fine (the panel is RGB per pixel), so a soft-edged 8×8 looks the part. The firmware never interprets the values — a screen is just pixels.
Why the bitmap travels in the payload: so a new screen or icon is a server deploy, never a firmware flash. Typical payloads remain only a few kilobytes.
The three top-edge buttons drive navigation locally, without touching the server:
| button | action |
|---|---|
| left | previous screen |
| middle — single click | pause / resume auto-rotation |
| middle — double click | standby (panel off, still running) — press to wake |
| middle — hold ~5s | deep sleep (powers down to save battery) — press to wake |
| right | next screen |
Stepping left or right resets the rotation timer, so a manual move never fights an auto-advance. A single click freezes the current screen; new data still fetches in the background and the frozen screen refreshes in place.
Standby vs. deep sleep. A double click blanks the panel but keeps the device running, so the next middle press wakes it instantly. Holding ~5s enters ESP32 deep sleep — a real power-down for battery life; the middle button wakes it, which reboots (reconnect WiFi, re-sync the clock, ~2–4s). Either way a press turns it back on.
The pins (left 26 / middle 27 / right 14) match the TC001. The piezo buzzer
(15) is held low at boot so it stays quiet.
The TC001 has a GL5516 ambient-light sensor on GPIO 35. Watchlight is
configured to average eight readings every 500ms and lower the matrix brightness
value from 40 to 10 in a dark room — 25% of the configured normal value.
It returns to normal only after the reading crosses a separate light threshold,
so sensor noise around one boundary cannot make the display flicker between
levels.
The two 12-bit ADC thresholds are starting points because the LDR and enclosure
vary between devices. AMBIENT_DARK_ADC_ENTER and AMBIENT_DARK_ADC_EXIT live
in src/config.h. After flashing, use mise run monitor to capture the raw
reading at boot in known dark and light conditions; state transitions and regular
poll logs also include the reading and chosen brightness.
The device also renders a few screens from data it reads itself, so they work with
or without the server (and even offline). The hardware was confirmed with an I2C
scan: an SHT3x temp/humidity sensor at 0x44 and a DS3231 RTC at 0x68,
both on the I2C bus (SDA 21 / SCL 22).
- Clock — a 9px-wide white calendar page with a red header and the
day-of-month cut out of the white (dark digits on the page), then
HH:MM(24h) to the right with a colon that fades out and in each second. The RTC keeps time across reboots; whenever WiFi is up the firmware calibrates it from NTP. Timezone and NTP servers live insrc/config.h(default: São Paulo, UTC-3). Until the clock is set it shows dashes. - Temperature / humidity — one panel showing both at once: a thermometer with the temperature (°C) and, beside it, the humidity (%RH).
- Low battery — appears only when the charge falls to 15% or less: a gray
battery whose red bar breathes (fades in and out), with the % beside it. It
joins the rotation; and if rotation is paused, a low battery forces this
screen to the front until the charge recovers. The battery is read on ADC pin
34— the empty/full raw values inmain.cppneed calibrating on hardware (mise run monitorprintsbatt=NN% (adc XXXX)to help).
Numbers on these screens use the same hand-drawn 3×5 digits, for a consistent pixel-clock look. They sit after the server screens in the rotation and are reachable with the left/right buttons like any other screen.
If the last successful fetch is older than staleAfter (or none has happened
yet), Watchlight drops the server screens from the rotation and shows an amber
warning triangle in their place. This is deliberate: a panel showing yesterday's
number with a fresh face is worse than one that admits it's blind. The local
screens (clock, temp/humidity) keep rotating — they're never stale.
The repository includes a Wokwi circuit for firmware development without a
physical TC001. It runs the real src/main.cpp against a simulated ESP32,
32×8 serpentine WS2812 matrix, three active-low buttons, buzzer, and adjustable
battery and ambient-light inputs.
The Wokwi target deliberately uses the local src/secrets.h: API_URL,
API_TOKEN, and API_ROOT_CA are the same values used by the physical device.
Only WiFi association is overridden, because Wokwi cannot see the physical
networks in WIFI_NETWORKS; the simulator joins its built-in Wokwi-GUEST
network instead. As a result, the simulated firmware artifact contains the local
credentials. Keep it private and do not share the artifact or simulation session.
For the visual simulator:
- Run
mise run simulate, which defaults tomise run simulate:vscode. It builds the Wokwi target, opens this workspace in VS Code, and selectsdiagram.json. - On the first run, install the workspace's recommended extensions and activate the Wokwi license.
- Press the green play button (or run Wokwi: Start Simulator from the command palette).
The checked-in .vscode configuration recommends Wokwi, PlatformIO, C/C++, and
TOML support. Its tasks call the same mise commands used in the terminal, so
Terminal → Run Task also exposes build, Wokwi, check, format, upload, and
monitor actions.
Use the left/right arrow keys to navigate and the space bar for the middle button. The battery potentiometer drives GPIO 34; turn it down to exercise the low-battery screen. The ambient-light potentiometer drives GPIO 35; turn it below the dark threshold to reduce brightness to 25%, then above the light threshold to restore it. Button bounce remains enabled so the firmware's real debounce logic is exercised.
For headless terminal runs, create a Wokwi CI token and configure it locally:
cp mise.local.example.toml mise.local.tomlReplace the placeholder in mise.local.toml, trust the local config if prompted,
then run mise run simulate:cli. The Wokwi CLI itself is installed by mise;
mise.local.toml and its optional lockfile are gitignored and preserved by
mise run clean.
The current circuit models the matrix, buttons, buzzer, battery ADC,
ambient-light ADC, and clock. An adjustable potentiometer stands in for the
TC001's exact GL5516 response. Wokwi's DS1307 stands in for the TC001's DS3231
because both expose the basic RTC behavior through I2C address 0x68;
DS3231-specific behavior remains outside the simulator's scope. Wokwi does not
provide the exact SHT3x part, so the temperature/humidity screen still needs
hardware verification. Wokwi also cannot replace final orientation and
electrical checks on real hardware.
- Copy
src/secrets.example.h→src/secrets.hand fill in your WiFi networks,API_URL, andAPI_TOKEN.secrets.his gitignored. Non-secret knobs (brightness and ambient-light thresholds, screen rotation, fresh/stale polling, clock timezone, NTP servers, and temperature calibration) live insrc/config.h, which is committed — edit it in place. - Build and flash — the task runner is
mise(wrapping PlatformIO):The upload usesmise run build # compile mise run check # static analysis + Wokwi diagram validation mise run lint # format the firmware (clang-format) mise run simulate:build # compile the Wokwi target mise run simulate # visual Wokwi simulator in VS Code (default) mise run simulate:vscode # explicitly select the VS Code simulator mise run simulate:cli # run headlessly (requires WOKWI_CLI_TOKEN) mise run upload # build + flash over USB mise run monitor # serial logs at 115200
upload_speed = 115200(higher rates were unreliable on the test adapter). If a flash drops mid-transfer, retry — it's usually the USB cable. - To revert to stock, reflash the Ulanzi firmware (keep a backup
.bin).
API_ROOT_CA is optional. Empty = skip TLS validation (setInsecure), which is
fine on trusted WiFi but lets a machine on the same network read the token. Pin
the endpoint's root CA when the display lives on untrusted WiFi.
Confirmed on the Ulanzi TC001. On a different board, check these first:
- Matrix. 32×8 on
MATRIX_PIN = 32, wired serpentine — the code usesNEO_MATRIX_ZIGZAG. WithPROGRESSIVEa compact drawing splits into a left half and a mirrored right half (the tell-tale of the wrong flag). - Buttons / buzzer. Left
26, middle27, right14(active-low, internal pull-ups); piezo buzzer15, held low so it stays silent. - Sensors. I2C
SDA 21/SCL 22; SHT3x temp/humidity at0x44, DS3231 RTC at0x68. - Ambient light. GL5516 LDR on ADC pin
35; darker rooms produce lower raw readings. Tune the enter/exit thresholds insrc/config.hfrom values printed bymise run monitoron the physical device. - Battery. Read on ADC pin
34; the empty/full raw values inmain.cppare a starting point — calibrate frommise run monitor(batt=NN% (adc XXXX)). The SHT3x reads a few degrees high from nearby self-heating. - Deep-sleep wake. The middle button wakes the ESP32 from deep sleep via
ext0on GPIO 27. If a press won't wake it, that's the pin to check. - Text fit. With the small font, values up to ~8 characters fit centered in the 24px text area; longer ones crop on the right.
The clock digits, calendar, thermometer, and the °/% marks are hand-drawn bitmaps
in main.cpp — tweak them there for a different look. mise run monitor prints
boot, WiFi, fetch, NTP, sensor, brightness, and button events for troubleshooting.
Hardware
- Ulanzi TC001 — the target 32×8 pixel-clock display (ESP32 + WS2812B), on AliExpress
Tooling
- mise — task runner and hermetic tool manager (
mise run …) - PlatformIO — build/flash/monitor toolchain for the ESP32
- Wokwi — ESP32, circuit, and 32×8 matrix simulation
Libraries (see platformio.ini)
- Adafruit NeoMatrix + NeoPixel — drive the 32×8 WS2812B matrix
- Adafruit GFX — text and graphics primitives (incl. the TomThumb font)
- ArduinoJson — streaming parse of the payload
- RTClib — DS3231 real-time clock
- Adafruit SHT31 — SHT3x temperature/humidity sensor
Icons
- LaMetric icon gallery — the 8×8 icons the server sends (each has a numeric ID; the payload carries the RGB bitmap)