Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

upytc001 — MicroPython clock for the Ulanzi TC001

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.

Ulanzi TC001 running this firmware, showing 23:38 in red

Requirements

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 mpremote
  • esptool — flashes firmware to the ESP32 over USB (used to back up the stock firmware and install MicroPython).
  • mpremote — copies your .py files 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.

Files

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 targets

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.


1. Configure — copy the template first

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

Edit 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.30 mirrors the EHMTXv2 color_correct: 30% so the panel isn't blinding. If the LED order ever looks wrong (e.g. mirrored), the serpentine map in Matrix.pixel (main.py) is the place to flip.

Re-run cp config.default.py config.py only on a fresh checkout — it overwrites your edited config.

2. Test mode (no hardware)

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


3. Back up the current (stock/EHMTXv2) firmware ← run on the HOST

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-stub if 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.bin

Keep 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.bin

Tip: 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.


4. Flash MicroPython

make install-micropython

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

5. Upload the clock files

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 reset

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

Buttons on the device

  • Left / Right — nudge brightness down/up (switches to manual).
  • Middle — back to automatic (light-sensor) brightness.

Home Assistant (clock color)

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.

Pushing readings up to HA (telemetry)

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

Time source & notes

  • NTP + ESP32 internal RTC: at boot (after Wi-Fi) and every NTP_RESYNC_S the firmware sets the ESP32's built-in RTC from NTP_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 using now().utcoffset()), the same read sets the offset, so DST is handled with no rules in the firmware. TZ_OFFSET_HOURS is the fallback until HA answers (and the fixed offset when USE_HA is 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_HA is on (HA supplies the offset). With HA off, it's the fixed TZ_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.

Sources

License

MIT © 2026 Sylvain Zimmer — see LICENSE.

About

MicroPython firmware for the Ulanzi TC001 clock. Supports NTP and Home Assistant.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages