Everything you need to build, flash, connect, and drive this clock solo.
A SI HAI IPS Clock: an ESP32 (WROOM-32D, 16 MB flash) driving 6 ST7789 135×240
IPS panels in glass tubes, with a WS2812 RGB LED under each and 4 buttons. The panels
are on a shared SPI bus selected by a 74HC595 shift register. We replaced the broken
stock firmware with our own firmware (firmware/custom-fw/) that exposes an HTTP REST
API so scripts can push text / colors / images to any tube. Full pin map:
hardware/pinmap.md.
esptube-clock/
├── firmware/
│ ├── custom-fw/ ← THE firmware source you edit (PlatformIO project)
│ │ ├── src/ main.cpp, tubes.*, leds.*, api.*, config.h
│ │ ├── lib/modified_TFT_eSPI/ patched display driver (needed for ESP32 core 3.x)
│ │ ├── include/secrets.h(.example)
│ │ └── platformio.ini (build_dir points to LOCAL disk, not iCloud)
│ ├── esptube-custom-B.bin last-flashed binary
│ ├── stock-backup*.* partial stock flash backup + log
│ └── FLASHING.md raw esptool commands / restore notes
├── tools/
│ ├── esptube REST CLI (Python, no deps)
│ ├── flash.sh build + USB-flash
│ └── monitor.sh serial console
├── hardware/pinmap.md the confirmed SI HAI pin map
├── docs/ this guide + spec/identification/connection docs
└── images/ photos (+ manifest) for the eventual blog
Builds are written to /Users/sm/Developer/esptube-build/ (local, so iCloud isn’t thrashed).
The EleksTubeHAX reference firmware lives at ~/Developer/esptube-clock/EleksTubeHAX/.
pio) and esptool — via Homebrew. If missing: brew install platformio esptool.~/.platformio.One command (build + USB flash):
cd "…/esptube-clock/tools"
./flash.sh # uses /dev/cu.usbserial-1220 (the powered hub)
The clock powers/enumerates only through the powered USB hub, not the Mac’s port directly. Small (~1 MB) writes are reliable; a full 16 MB read is not (that’s why the stock backup is partial).
Build only: pio run -d ../firmware/custom-fw -e esptube
Update over WiFi (no cable) after the first flash:
pio run -d ../firmware/custom-fw -e esptube -t upload --upload-port esptube.local
# or: curl --data-binary @/Users/sm/Developer/esptube-build/esptube/firmware.bin http://<ip>/ota
If esptool can’t sync over USB, hold nothing — our board auto-resets; just retry. If truly stuck,
drop --baud to 115200 (already the default in flash.sh).
esptube-setup.http://192.168.4.1, pick your 2.4 GHz network (YOUR_WIFI_SSID), enter the password
on the device (never stored in the source).esptube.local (mDNS) or check esptube status / your router.
Current IP: **Gotcha: on the very first boot where you enter creds, the HTTP server can bind before the WiFi interface exists → connection refused until one reboot. Just power-cycle once. (On the fix list.)
Set the address once: export ESPTUBE_IP=<clock-ip> (or rely on esptube.local).
With the CLI (tools/esptube):
./esptube status # JSON: ip, heap, per-tube alive/populated, dead_tubes
./esptube health
./esptube text 0 HI # draw "HI" on tube 0 (0 = far RIGHT)
./esptube rgb 0 255 0 0 # tube 0 underglow -> red
./esptube image 3 face.bmp # blit a 24- or 8-bit BMP (135×240) to tube 3
./esptube clear # blank all tubes
./esptube button mode # inject a virtual button (mode|left|right|power)
Raw HTTP (same thing, for your own scripts in any language):
| Method & path | Body | Effect |
|—|—|—|
| GET /status | — | full state JSON |
| GET /health | — | {"ok":true} |
| POST /tube/{0-5}/text | raw text | draw centered text |
| POST /tube/{0-5}/rgb | {"r":N,"g":N,"b":N} | set that tube’s underglow |
| POST /tube/{0-5}/image | BMP bytes (multipart) | blit image (24/8-bit, centered; ≤110 KB) |
| POST /tube/{0-5}/raw565 | 64,800 B RGB565 big-endian (multipart) | full-frame push, streamed row by row |
| POST /tube/{0-5}/blit?x=&y=&w=&h= | w×h×2 B RGB565 big-endian (multipart) | write just that rectangle (clamped to the panel) — what the helper sends for changed row bands |
| POST /reboot | — | restart the clock (~0.5 s later) |
| POST /tubes/clear | — | blank all |
| POST /mode/{clock\|off\|manual} | — | display layer |
| POST /led/{off\|solid\|rainbow\|breathe\|comet} | solid: {"r","g","b"} optional | underglow layer (runs over the display) |
| POST /preset/{next\|0-4} | — | device-native face: 0 🕯️ Nixie · 1 Digital · 2 LED show · 3 Off · 4 📅 Date (what short MODE cycles) |
| POST /nixie/text | {"text":"HELLO","effect":"static\|flash\|scroll","ms":400} | message in nixie glyphs, rendered by the clock (A–Z 0–9 - : . ! ? ° % + /) |
| POST /nixie/countdown | {"seconds":90} | countdown (HHMMSS / MMSS), flashes 0000 at the end, back to the clock |
| POST /nixie/stop | — | back to the clock |
| POST /config/brightness · /config/tz · /config/populated | {"value":N} · {"tz":"POSIX"} · {"mask":N} | persisted settings |
| POST /test/clocksweep · /test/stop | {"from":0,"to":86399,"step_ms":0,"fade":1} | drive every time in the range through the real render path; progress in /status.sweep |
| POST /button/{mode\|up\|down\|power} | — | virtual button (same handler as the physical ones) |
| POST /ota | firmware.bin | over-the-air firmware update |
GET /status also reports preset, led_effect, draw_ms (last nixie draw), reset_reason
(crash vs. power-cycle), nixie (when a message is showing) and sweep (test progress).
Tests: test/rest-smoke.sh <ip> (38 assertions against a live clock, including region blits, the
nixie endpoints, the Date face and a midnight clock-sweep slice), test/clock-sweep.sh <ip> [from] [to] [fade] (every valid time through the real
render path — whole day ≈ 8 min without fades on the 5-tube layout, ~4× with), and test/nixie-native.sh (no hardware: every glyph
pair × every fade step, and all 86,400 clock times through the transition logic).
Tube index model: index 0 = far right, increasing to the left (0..5). Each index is a fixed physical slot on the shift register — plug a panel into a slot and it shows that index’s content; empty slots stay dark. Rearrange panels freely; indices stay tied to slots.
Example — push the time from a cron/script:
now=$(date +%H%M%S) # 6 digits; tube 1 is dark so it'll have a gap
for i in 0 2 3 4 5; do ./esptube text $i "${now:$((5-i)):1}"; done
cd "…/esptube-clock/tools" && ./monitor.sh # 115200; Ctrl-C to quit
The firmware logs boot, tube detect read-back, WiFi, and each REST call. (Serial needs the USB hub connection; REST/OTA do not.)
firmware/custom-fw/src/)config.h — pins, tube count, DEAD_TUBES_MASK (manual force-off), hostname.tubes.{h,cpp} — the display layer: 74HC595 chip-select (csWrite), tft.init(),
selectTube/deselect, drawText, drawImageBMP (BMP parser), fill, clearAll, selfTest,
and the (currently non-functional) read-back detectPopulated.leds.{h,cpp} — WS2812 wrapper (Adafruit NeoPixel) on GPIO32.api.{h,cpp} — REST routes + body parsing + the virtual-button queue.main.cpp — boot order (tubes → self-test → LittleFS → non-blocking WiFiManager → mDNS →
REST server → ArduinoOTA), and loop().To add an endpoint: add a route in api.cpp, call into Tubes::/Leds::, rebuild, ./flash.sh
(or OTA). To change a pin: config.h and the matching -D in platformio.ini (the display
pins live in build_flags; config.h’s SPI macros are documentation only).
| Symptom | Cause / fix |
|---|---|
| Panels backlit but blank | Wrong variant/pins — this board is SI HAI (shift-register CS). Config is already correct; if you fork it, keep CGRAM_OFFSET and the 595 CS. |
curl connection refused, but ping works |
HTTP server bound before WiFi came up (first-config boot). Power-cycle once. |
| Nothing enumerates over USB | Must go through the powered hub; the Mac’s port alone won’t power it. |
| A tube stays dark | Empty/dead slot (e.g. tube 1’s torn flex) — expected; not a bug. |
esptube.local doesn’t resolve |
Use the IP; mDNS is flaky on some networks/VLANs. |
| Big flash read fails | Known — the USB link is fine for ~1 MB writes, not 16 MB reads. |
It looked like an EleksTube “IPSTube” clone, so the first firmware used direct-GPIO chip-select —
and drew nothing but backlight. Five iterations of correct-looking code later, a top-down photo
showed the silkscreen: SI HAI IPS CLOCK, which selects its panels through a 74HC595 shift
register on entirely different pins. One variant switch and the glass lit up. The read-back
auto-detect we tried afterward proved the panels don’t support SPI reads at all — a fine dead end
to document. Full arc + photos are in images/ and the docs/ history.