A minimal MicroPython clock for the Ulanzi TC001 pixel display: shows the time (HH:MM), auto-brightness from the light sensor, NTP sync over Wi-Fi from a LAN server, and recolors the clock from a Home Assistant entity. Includes a terminal simulator so you can iterate on a PC before touching the device.
Hardware: a Ulanzi TC001, and a USB-C data cable — many cheap cables are charge-only and won't appear as a serial port.
On your computer: Python 3, plus two command-line tools:
pip install esptool mpremoteesptool— flashes firmware to the ESP32 over USB (used to back up the stock firmware and install MicroPython).mpremote— copies your.pyfiles onto the board and opens a REPL.
MicroPython is Python that runs on the
microcontroller itself; make install-micropython downloads and flashes it for
you. The simulator needs only Python 3 and a truecolor terminal — no hardware.
macOS: the TC001's CH340 USB-serial chip usually works out of the box on recent macOS. If no
/dev/cu.wchusbserial*port shows up, install the WCH CH34x driver.
Uploaded to the device:
| File | Role |
|---|---|
main.py |
Firmware entry point: hardware setup, Wi-Fi/NTP/HA, main loop. |
clock.py |
Draws HH:MM centered on the matrix. |
font.py |
7×3 pixel font (tall digits + colon). |
config.py |
Your settings — Wi-Fi, NTP, timezone, brightness, HA. Not in git. |
Host-only (never uploaded):
| File | Role |
|---|---|
simulator.py |
Fakes the hardware modules and runs main.py on a PC. |
config.default.py |
Template for config.py (committed; copy & edit). |
main.py is pure MicroPython and talks straight to the hardware; the simulator
fakes those modules from the outside, so no test code ships to the clock.
The hardware details (serpentine pixel map, GPIO pins, GRB order) match the
working EHMTXv2 config and the rroels/ulanzi_tc001_hardware
reference.
make (no args) lists everything. The common ones:
| Command | What it does |
|---|---|
make config |
Create config.py from the template (won't overwrite). |
make sim |
Run the terminal simulator (sim-dark / sim-bright too). |
make check |
Byte-compile all .py to catch syntax errors. |
make backup |
Read the full flash to tc001-stock-backup.bin. |
make install-micropython |
Erase flash and flash the MicroPython runtime. |
make upload |
Copy the device files to the clock and reset. |
make repl |
Open a REPL on the device. |
make restore |
Write the backup image back (revert to stock). |
First-time device setup, in order: make backup → make install-micropython
→ make config (once, then edit it) → make upload. Day-to-day you
just edit and re-run make upload.
Everything except sim/check/config needs the device attached, so run those
on the computer it's plugged into. The serial port is auto-detected; only set PORT
if you have several boards. Override variables per command, e.g.
make upload PORT=/dev/cu.usbserial-0001 or
make install-micropython MPY_VERSION=v1.27.0 MPY_DATE=20251015. Defaults:
PORT (auto), BAUD=115200, FLASH_SIZE=0x1000000 (16MB), MicroPython v1.28.0.
The sections below explain each step in full; the make targets are just
shortcuts for them.
config.py holds your Wi-Fi password and HA token, so it is git-ignored.
Create it from the template, then edit:
cp config.default.py config.pyEdit config.py:
WIFI_SSID,WIFI_PASSWORD— your network (hardcoded).NTP_HOST— your LAN NTP server IP (e.g.192.168.1.2).TZ_OFFSET_HOURS— offset from UTC. No automatic DST, so change this by hand at the DST switch, or keep it fixed if you don't care.HA_BASE,HA_TOKEN,HA_ENTITY,HA_COLOR_MAP— Home Assistant (see below).BRIGHTNESS_MIN/MAX,INTENSITY,COLOR_DEFAULT— looks.INTENSITY=0.30mirrors the EHMTXv2color_correct: 30%so the panel isn't blinding. If the LED order ever looks wrong (e.g. mirrored), the serpentine map inMatrix.pixel(main.py) is the place to flip.
Re-run
cp config.default.py config.pyonly on a fresh checkout — it overwrites your edited config.
python3 simulator.py # renders the matrix to your terminal; Ctrl-C to quit
SIM_LIGHT=0.0 python3 simulator.py # simulate a dark room (dim)
SIM_LIGHT=1.0 python3 simulator.py # simulate bright light (bright)simulator.py injects fake machine/neopixel/network/ntptime modules,
then imports and runs main.py unchanged, drawing each LED as a half-block
character (two LED rows per text line). The Home Assistant call is not faked:
ha_fetch() queries your real HA, so the clock color reflects live state (run the
simulator on a machine that can reach HA_BASE). To skip HA entirely, set
USE_HA = False in config.py; an unreachable HA just falls back to COLOR_DEFAULT.
Needs a truecolor terminal (iTerm2, recent macOS Terminal, VS Code's terminal all work).
esptool talks to the USB serial chip, so run these on the computer the clock is
plugged into. The TC001 uses a CH340 USB-UART; on macOS the port shows up as
/dev/cu.wchusbserial* (Linux: /dev/ttyUSB0, Windows: a COM port). If no port
appears on macOS, install the WCH CH34x driver.
CH340 + high baud = corruption. This adapter often drops bytes above ~230400 ("Serial data stream stopped: Possible serial noise"). Omit
--baud(defaults to 115200) for a reliable full-flash read; add--no-stubif it still stalls. Use a direct USB port and a real data cable, not a hub.
# 1. (optional) esptool auto-detects the port; to check it yourself:
ls /dev/cu.* # the TC001 (CH340) shows up as /dev/cu.wchusbserial-XXXX
# 2. Confirm the chip and FLASH SIZE. These boards are commonly 16MB
# ("Detected flash size: 16MB" -> 0x1000000); some are 4MB (0x400000).
esptool flash-id
# 3. Full flash backup. Use the size from step 2 as the length.
# 16MB = 0x1000000 (16,777,216 bytes). Omit --baud (115200) to avoid CH340 corruption.
esptool --baud 115200 read-flash 0x0 0x1000000 tc001-stock-backup.binKeep tc001-stock-backup.bin safe — that's your full restore image.
To restore the stock firmware later:
esptool --baud 115200 write-flash 0x0 tc001-stock-backup.binTip: a full read is the only true backup. The EHMTXv2/ESPHome firmware itself can also always be re-flashed from your ESPHome config, but the raw backup also preserves calibration/RTC data and is provider-agnostic.
make install-micropythonThis downloads the ESP32 firmware (if not already present), erases the flash,
and writes MicroPython at offset 0x1000 (correct for the classic ESP32 in the
TC001). Override the version with MPY_VERSION=... MPY_DATE=... — check
micropython.org/download/ESP32_GENERIC
for the latest. The equivalent by hand:
curl -fLO https://micropython.org/resources/firmware/ESP32_GENERIC-20260406-v1.28.0.bin
esptool --port "$PORT" erase-flash
esptool --port "$PORT" --baud 115200 write-flash 0x1000 ESP32_GENERIC-20260406-v1.28.0.bin(S3/C3 boards flash at 0x0, but the TC001 is a classic ESP32, so 0x1000.)
esptool only deals with flash images, not .py files. Use mpremote
(simplest) or Thonny:
pip install mpremote
cd /path/to/upytc001
mpremote cp config.py font.py clock.py main.py : # add `connect <port>` to force a port
mpremote resetUpload only those four files — not simulator.py or config.default.py.
The device boots, runs main.py, connects to Wi-Fi, NTP-syncs, and shows the
clock. To watch logs / get a REPL: mpremote connect "$PORT" repl (Ctrl-C
interrupts the running clock, Ctrl-D soft-reboots).
- Left / Right — nudge brightness down/up (switches to manual).
- Middle — back to automatic (light-sensor) brightness.
The clock color is driven by an HA entity over the REST API — HTTP, or HTTPS
when HA_BASE is an https:// URL (TLS via ssl.wrap_socket). In config.py:
USE_HA, HA_BASE, HA_TOKEN, HA_ENTITY, and HA_COLOR_MAP mapping the
entity's value to a color (e.g. input_select.house_mode:
Home→green, Away→red, Night→blue). It falls back to COLOR_DEFAULT when the
value is unmapped or HA is unreachable, and a failed poll never crashes the
clock — it just keeps the last color. HA_FIELD chooses state (default) or
an attribute name. Polls every HA_POLL_S seconds.
It's a single GET of HA_ENTITY from /api/states — works with a non-admin
token. If that entity's state is bundled as "<value>|<offset_secs>" (e.g. an HA
template sensor combining your status with now().utcoffset()), the same
read also sets the clock's UTC offset, giving automatic DST. A plain entity works
too; the offset then falls back to TZ_OFFSET_HOURS. HTTP, or HTTPS when
HA_BASE is https://.
To bundle the timezone, create a Template sensor helper (Settings → Devices &
Services → Helpers → Template) whose state is e.g.
{{ states('input_select.house_mode') }}|{{ now().utcoffset().total_seconds()|int }}
and point HA_ENTITY at it.
Or define the colors in HA: leave HA_COLOR_MAP empty ({}) and have the
template emit r,g,b or #rrggbb (before the |); the firmware then uses it
directly, so the whole status→color map lives in the template sensor. A non-empty
HA_COLOR_MAP always takes precedence (firmware-side mapping of status keys).
All network — the HA request and the periodic NTP re-sync — runs on a
background thread (_thread), so a slow or unreachable server never freezes the
render loop. The loop only reads the last values the thread published.
Create the token in HA → profile → Security → Long-Lived Access Tokens.
The clock writes its readings into HA input_number helpers via the
input_number.set_value service (one call per reading, every TELEMETRY_S).
That service works with a non-admin token — unlike POST /api/states, which
is admin-only. Available readings: light (%), temperature (°C),
humidity (%), battery (%). Create one helper per reading you want
(Settings → Helpers → Number), then map it in config.py — leave "" to
skip, and a skipped reading's hardware isn't read at all:
HA_PUBLISH = {
"light": "input_number.clock1_light",
"temperature": "input_number.clock1_temp",
"humidity": "", # not published -> SHT3x still read for temperature
"battery": "input_number.clock1_battery",
}Temperature and humidity come from the SHT3x in one I²C read. Each helper
persists and gets a history graph. (Want them as sensor.? Add a one-line
template sensor in HA.)
- NTP + ESP32 internal RTC: at boot (after Wi-Fi) and every
NTP_RESYNC_Sthe firmware sets the ESP32's built-in RTC fromNTP_HOST(UTC);now_local()reads that RTC and adds the current UTC offset. - Timezone/DST is automatic via HA. When
HA_ENTITY's state bundles the offset ("<value>|<offset_secs>"from a template sensor usingnow().utcoffset()), the same read sets the offset, so DST is handled with no rules in the firmware.TZ_OFFSET_HOURSis the fallback until HA answers (and the fixed offset whenUSE_HAis off or the entity isn't bundled). - Drift between syncs is negligible. The clock runs continuously (no deep sleep), so timekeeping is governed by the 40 MHz crystal at ±10 ppm ≈ ~0.04 s/hour (~0.8 s/day), or ~0.15 s/hour even at a pessimistic ±40 ppm. A 10-minute re-sync keeps it far tighter than the minute we actually display, so NTP alone is enough. (The "minutes per day" drift you see online is the 150 kHz RC oscillator during deep sleep — not applicable here.)
- DS1307 (battery-backed RTC) — optional, not used. The board has a DS1307 on the I²C bus (SDA21/SCL22). We don't read it, so after a power-cut the clock shows no valid time until the first NTP sync succeeds (a few seconds on a healthy LAN). If that ever matters, it could be added: read the DS1307 on boot to set the time instantly, and write fresh UTC back after each NTP sync. Given how fast and reliable a LAN NTP sync is, it isn't worth the extra code.
- DST: automatic when
USE_HAis on (HA supplies the offset). With HA off, it's the fixedTZ_OFFSET_HOURS— adjust by hand at the switch. - Temp / humidity / battery: read from the on-board SHT3x (I²C) and the battery divider and pushed to HA — see telemetry.
- rroels/ulanzi_tc001_hardware
- EsphoMaTrix v2 (lubeda)
- Ulanzi TC001 Arduino programming notes (calumk)
MIT © 2026 Sylvain Zimmer — see LICENSE.
