esptube-clock

ESPTube Clock — Hacking Guide

Everything you need to build, flash, connect, and drive this clock solo.


1. What it is (1-paragraph recap)

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.

2. What works today

3. Layout

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/.

4. Prerequisites (already installed on this Mac)

5. Build & flash

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).

6. Connect to WiFi

  1. Power the clock. On first boot (or if creds are cleared) it makes an open WiFi network esptube-setup.
  2. Join it, open 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).
  3. It reboots onto your WiFi. Find it: 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.)

7. Drive it — REST API

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

8. Watch the serial log (debugging)

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.)

9. Firmware architecture (firmware/custom-fw/src/)

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).

10. Troubleshooting

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.

11. The story (for the blog)

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.