WiFi-connected MP3 doorbell that plays a configurable or random chime when a UniFi Protect G4 Doorbell (or any HTTP webhook source) triggers it. Runs MicroPython on an ESP32-C3 SuperMini driving an OPEN-SMART Serial Control USB MP3 module.
UniFi Protect / Home Assistant
│ HTTP webhook
▼
┌────────────────────┐ ┌───────────────────────┐
│ ESP32-C3 SuperMini│ UART │ OPEN-SMART Serial MP3 │
│ (MicroPython) │ ──────▶ │ + microSD + speaker │
└────────────────────┘ └───────────────────────┘
- MCU: ESP32-C3 SuperMini (~50 SEK). WiFi 4 + native USB-Serial/JTAG.
- Audio module: OPEN-SMART Serial Control USB MP3 v1.1. 3 W onboard amp, microSD up to 32 GB, headphone jack.
- Speaker: 8 Ω 1–3 W on the module's XH2.54 connector.
- Storage: Any microSD card ≤ 32 GB, FAT16 or FAT32.
| ESP32-C3 mini | Open-Smart MP3 |
|---|---|
| 5V | VCC |
| GND | GND |
| GPIO 21 (UART1 TX) | RX |
| GPIO 20 (UART1 RX) | TX |
GPIO numbers are configurable in main.py (UART_TX / UART_RX). Speaker
connects to the XH2.54 header or the 3.5 mm jack on the module.
See case/ for an OpenSCAD model and pre-built STL files
(box + front panel + hex grille) sized for the speaker, the ESP32-C3, and
the MP3 module. Build with make all (requires OpenSCAD in PATH).
Format FAT16 or FAT32 (≤ 32 GB). Copy files named 001*.mp3, 002*.mp3, …
into the root one at a time, in the order you want them indexed — the
module reads files in FAT directory order, not alphabetically.
The chip is picky about MP3 encoding. Reliable settings: 22.05 kHz mono,
64 kbps CBR, no ID3 tags. Convert any source file with ffmpeg:
ffmpeg -i source.mp3 -ar 22050 -ac 1 -b:a 64k -id3v2_version 0 -map_metadata -1 001.mp3Files at 48 kHz stereo VBR with ID3v2 tags are sometimes accepted but silently no-op often enough to be unreliable.
Update TRACK_COUNT in main.py to match the number of files.
# 1. MicroPython 1.22+ firmware
esptool.py --chip esp32c3 --port /dev/tty.usbmodem* erase_flash
esptool.py --chip esp32c3 --port /dev/tty.usbmodem* --baud 460800 \
write_flash 0x0 ESP32_GENERIC_C3-*.bin
# 2. Project files (edit WIFI_SSID/WIFI_PASS/TRACK_COUNT in main.py first)
mpremote connect /dev/tty.usbmodem* cp boot.py main.py mp3.py http_parse.py :
# 3. Reset and watch serial
mpremote connect /dev/tty.usbmodem* reset
mpremote connect /dev/tty.usbmodem* # opens REPL/serial monitorYou should see within 30 s:
wifi: connected, ('192.168.x.y', '255.255.255.0', '192.168.x.1', '192.168.x.1')
http: listening on :80
Note the IP and add a DHCP reservation in your router so it stays fixed.
# Play track N at default volume (25/30)
curl "http://<ip>/ring?track=1"
# Play random track at custom volume (0..30)
curl "http://<ip>/ring?track=random&vol=20"
# POST equivalent
curl -X POST -H 'Content-Type: application/json' \
-d '{"track":"random","vol":15}' \
http://<ip>/ring
# Health check
curl http://<ip>/health| Endpoint | Method | Response |
|---|---|---|
/ring?track=N&vol=V |
GET | 200 {"status":"playing","track":N} or 409 {"status":"busy"} |
/ring?track=random&vol=V |
GET | 200 with the randomly chosen track |
/ring |
POST (JSON) | Same semantics as GET |
/health |
GET | 200 {"ok":true,"playing":bool} |
Validation errors return 400 {"error":"…"}. Method not allowed → 405.
No auth — relies on LAN isolation; add a reverse proxy if you need it.
UniFi Protect doesn't send HTTP webhooks natively as of firmware 4.x. Two integration paths:
-
Install the UniFi Protect integration in Home Assistant.
-
Add an automation triggered on the "doorbell" sensor event:
alias: Ring the chime triggers: - platform: state entity_id: binary_sensor.frontdoor_doorbell to: "on" actions: - action: rest_command.ring_doorbell
-
Define the
rest_command:rest_command: ring_doorbell: url: "http://192.168.1.95/ring?track=random&vol=25" method: get
UniFi has a beta webhook action in Network Application. Configure it to
GET http://<ringklocka-ip>/ring?track=random&vol=25 and bind to the
doorbell device event. Behaviour varies between firmware versions.
Found during real flashing + testing on a 2026-05 batch of the OPEN-SMART module:
- The module boots in U-disk mode. A
[Set IC mode] reset(7E 03 35 05 EF) followed by[Select device] TF card(7E 03 35 01 EF) is required on every power-up. We do this insetup_player()with 2 s and 1 s settle times — shorter intervals are unreliable. - Per-play
select_sd. The module silently reverts to U-disk between plays on this firmware revision;_handle_ring()re-selects SD before everyplay_index()call. - MicroPython incompatibility:
bytes((cmd, *data))is rejected by MicroPython (SyntaxError: *x must be assignment target). Usebytes((cmd,)) + bytes(data). - Playback grace period: the module takes 500–800 ms to load the file
and report PLAYING via
get_status. Without an 800 ms grace before polling, the busy lock clears immediately. - USB-Serial-JTAG quirk: after flashing, the port can stick. Unplug
and replug if
mpremotereturns "could not enter raw repl".
cd ringklocka
python3 -m venv .venv && .venv/bin/pip install pytest
.venv/bin/python -m pytest tests/ -vCovers the pure modules (mp3.build_frame, Mp3Player against a fake
UART, http_parse.*). The async server and hardware integration are
verified manually with the checklist below.
After flashing, run through each:
- Boot: serial shows
wifi: connected+ IP within 30 s. curl http://<ip>/ring?track=1→ 200, audible playback.curl "http://<ip>/ring?track=random&vol=10"→ 200, random track, low volume.- Two
curlcalls within ~100 ms → first 200, second409 busy. - After playback ends,
curl http://<ip>/health→{"playing":false}. curl http://<ip>/ring?track=99→ 400track out of range.curl http://<ip>/ring?track=abc→ 400track must be an integer or 'random'.curl http://<ip>/ring→ 400missing track.- WiFi resilience: power off router for 30 s, restore — within ~10 s the serial log shows reconnect; new requests succeed.
curl http://<ip>/health→ 200{"ok":true,"playing":false}.
ringklocka/
├── boot.py # runs main on power-up
├── main.py # WiFi, async HTTP server, watch tasks
├── mp3.py # Mp3Player UART driver + build_frame
├── http_parse.py # request line + query parser
├── case/ # OpenSCAD 3D-printable enclosure
└── tests/ # pytest suite (host-runnable)
MIT.