{"id":50769632,"url":"https://github.com/gre/ring-my-tibetan-clock","last_synced_at":"2026-06-11T17:00:57.287Z","repository":{"id":353978564,"uuid":"1221628000","full_name":"gre/ring-my-tibetan-clock","owner":"gre","description":null,"archived":false,"fork":false,"pushed_at":"2026-04-26T14:07:26.000Z","size":506,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-26T16:09:13.058Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"C++","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/gre.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-26T13:26:47.000Z","updated_at":"2026-04-26T14:07:31.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/gre/ring-my-tibetan-clock","commit_stats":null,"previous_names":["gre/ring-my-tibetan-clock"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/gre/ring-my-tibetan-clock","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gre%2Fring-my-tibetan-clock","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gre%2Fring-my-tibetan-clock/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gre%2Fring-my-tibetan-clock/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gre%2Fring-my-tibetan-clock/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/gre","download_url":"https://codeload.github.com/gre/ring-my-tibetan-clock/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gre%2Fring-my-tibetan-clock/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34208761,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-11T02:00:06.485Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-11T17:00:56.104Z","updated_at":"2026-06-11T17:00:57.274Z","avatar_url":"https://github.com/gre.png","language":"C++","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Tibetan Clock — Firmware\n\nPlatformIO + Arduino firmware for an ESP32-C3 SuperMini that drives two MG90S servos to strike Tibetan singing bowls, controllable from Home Assistant over MQTT.\n\n![The clock — two MG90S servos with arms set up to strike singing bowls](images/servo-driven-bells.jpg)\n\n## Hardware\n\n**MCU**: ESP32-C3 SuperMini (160 MHz single-core RISC-V, 4 MB embedded flash, USB-Serial/JTAG).\n\n**Servos**: 2× MG90S, driven via 50 Hz PWM (14-bit duty resolution, 600 µs–2400 µs pulse, 90° = neutral).\n\n**Servo power gate**: N-channel MOSFET (or similar) on the servo rail. Gate driven by GPIO 6. **Blue LED** on the board lights when the gate is conducting (hardware indicator, not firmware-controlled).\n\n**Status LEDs**: 2× LEDs (green = success, red = error) directly driven by GPIOs 0 and 1.\n\n![Custom perfboard with the ESP32-C3, MOSFET gate, status LEDs, and pin headers for the two servos](images/custom-perfboard-circuit.jpg)\n\n### Pinout\n\n| GPIO | Direction | Role | Notes |\n|------|-----------|------|-------|\n| 0 | OUT | Status LED — green | active-high |\n| 1 | OUT | Status LED — red | active-high |\n| 3 | OUT (PWM) | Bell A servo signal | LEDC, 50 Hz, 14-bit |\n| 4 | OUT (PWM) | Bell B servo signal | LEDC, 50 Hz, 14-bit |\n| 6 | OUT | Bell power MOSFET gate | active-high (`BELL_POWER_ACTIVE_HIGH=1`); blue LED comes on when high |\n| USB | – | USB-Serial/JTAG (`Serial`) | flashing + console |\n\nPolarity of GPIO 6 is configurable via `BELL_POWER_ACTIVE_HIGH` in `src/servo.h`.\n\n### Schematic (logical)\n\n```\n          +5V (servo rail) ───┬──────────────┐\n                              │              │\n                         [MOSFET S]      [Blue LED]\n                              │              │\nGPIO6 ──[gate]── (rail) ──[MOSFET D] ────────┘  (LED indicates rail powered)\n                                              │\n                                              ├──────► Bell A servo VCC\n                                              └──────► Bell B servo VCC\n\nGPIO3 ──────────────────────────────────────────────► Bell A servo SIG\nGPIO4 ──────────────────────────────────────────────► Bell B servo SIG\nGND  ───────── common to ESP32, both servos, MOSFET source\n\nGPIO0 ──[R]──[Green LED]── GND\nGPIO1 ──[R]──[Red LED] ──── GND\n\nUSB-C ── ESP32-C3 SuperMini USB (debug + power for the MCU)\n```\n\n\u003e The ESP32 is USB-powered; the servo rail is a separate +5 V supply gated by the MOSFET. **Don't share** the MCU's 3V3 pin with the servos — under stall they spike enough current to brown the MCU out (this is why the firmware sleeps `capacitor_stabilization_ms` after toggling the gate).\n\n## Architecture\n\nThe firmware is split into small modules, each with a `Begin/Tick` (or `Init`) pair so `loop()` stays simple.\n\n```\nfirmware/\n├── platformio.ini             pioarduino fork, esp32-c3-devkitm-1, secrets via extra_configs\n├── private_config.ini.template build_flag stanza for WiFi/MQTT secrets\n├── private_config.ini          gitignored — local credentials\n└── src/\n    ├── main.cpp                setup() init order, loop() pumps each module's Tick\n    ├── servo.{h,cpp}           bell ring algorithm + rail power manager\n    ├── config.{h,cpp}          NVS-persisted per-bell intensity / count / center trim\n    ├── leds.{h,cpp}            LED state machine (Booting / WifiConnecting / Connected / Error / Ringing)\n    ├── wifi_conn.{h,cpp}       WiFi STA, auto-reconnect, exponential backoff\n    └── mqtt.{h,cpp}            PubSubClient + ArduinoJson, HA discovery, command dispatch\n```\n\n### Servo / ring algorithm\n\nThe servo arm sits at its per-bell `center` (~90°). A single ring is a **wind-up + release**:\n\n1. **Rail-up** (skipped if rail is already powered from a recent ring). Staggered cold-start: pre-arm only the targeted servo's PWM line at center, energize the MOSFET, wait `RAIL_WARMUP_MS` (1 s) for the cap to charge with a single servo loaded, then pre-arm the other servo. Avoids brownouts caused by both MG90S seeking center simultaneously through a charging cap.\n2. Re-issue the targeted servo at center (in case it drifted between rings), sleep `capacitor_stabilization_ms`.\n3. Repeat `count` times:\n   a. **Wind-up** (Phase A, `swing_up_step_ms` per degree): rotate the arm slowly from `center` to `center + intensity`, *away* from the bowl. The arm is being charged with potential energy.\n   b. Hold `release_pause_ms` (500 ms) at the wind-up position.\n   c. **Release** (Phase B, `swing_down_step_ms` per degree): drive the arm back to `center`. With `swing_down_step_ms = 0` (default), this is an instant snap — the arm whips back through the bowl and rings it.\n   d. Pad remaining time to `cycle_ms` (5 s default) before the next strike.\n4. Re-center both servos to their per-bell centers (the un-commanded bell may drift under brownout pressure during the snap).\n5. **Schedule** a MOSFET-off `RAIL_IDLE_HOLD_MS` (1.5 s) in the future, executed by `servoTick()` from the main loop. If another `ringBell` lands inside that window, the schedule is cancelled and the rail stays up — back-to-back rings share a single power cycle.\n\n\u003e The reference implementation rang the bowl on the *out* swing and used the slow step-back to settle. This port rings on the *back* swing — gentle wind-up, snap release. Both modes are reachable: set `swing_up_step_ms = 0` and `swing_down_step_ms \u003e 0` to flip the strike phase.\n\n### MQTT / Home Assistant integration\n\nAuto-discovery via the `homeassistant/...` topic prefix. The device exposes **3 buttons + 9 number sliders** (all under one HA device entry \"Tibetan Clock\"), plus an availability binary sensor. The bell number for ring commands is taken **from the topic, not the payload**.\n\n**Entities:**\n\n| HA entity | Component | Range | Default | Section |\n|-----------|-----------|-------|---------|---------|\n| `Ring Bell A` | button | – | – | Controls |\n| `Ring Bell B` | button | – | – | Controls |\n| `Calibrate` | button | – | – | Controls |\n| `Bell A Intensity` | number | 5–50° | 28 | Configuration |\n| `Bell B Intensity` | number | 5–50° | 20 | Configuration |\n| `Bell A Count` | number | 1–9 | 1 | Configuration |\n| `Bell B Count` | number | 1–9 | 3 | Configuration |\n| `Bell A Center` | number | 60–120° | 90 | Configuration |\n| `Bell B Center` | number | 60–120° | 85 | Configuration |\n| `Swing Up Step` | number | 0–30 ms | 20 | Configuration — wind-up speed (ms per degree) |\n| `Swing Down Step` | number | 0–10 ms | 0 | Configuration — release speed (ms per degree). 0 = snap = ring |\n| `Strike Cycle` | number | 500–10000 ms | 5000 | Configuration — total time per strike in a multi-strike ring |\n\nThe `Bell X Intensity` / `Count` / `Center` sliders are persisted in NVS and used as defaults when `Ring Bell X` is pressed without a payload (or with HA's default `\"PRESS\"` body).\n\nPressing `Ring Bell A` with an explicit JSON payload overrides per-press:\n\n```json\n{ \"intensity\": 30, \"count\": 3 }\n```\n\n**Calibration workflow:** Press `Calibrate` → the rail powers up and both servos hold at their per-bell centers for 10 s. While holding, dragging `Bell A/B Center` sliders moves the corresponding servo live (each slider change resets the 10 s timer). When alignment looks right, stop interacting; after 10 s of inactivity the rail powers off.\n\n**Topics** (`homeassistant/...` prefix):\n\n| Topic | Direction | Retain | Notes |\n|-------|-----------|--------|-------|\n| `binary_sensor/tibetan_clock/availability` | publish | yes | `online` / `offline`. LWT auto-publishes `offline` on unclean disconnect — HA depends on this. |\n| `binary_sensor/tibetan_clock/state` | publish | no | `ringing` / `idle` (informational) |\n| `button/tibetan_clock/ring_bell_a/config` + `_b/config` + `calibrate/config` | publish | yes | discovery payloads for the 3 buttons |\n| `button/tibetan_clock/ring_bell_a/command` + `_b/command` + `calibrate/command` | subscribe | – | trigger ring or calibration |\n| `number/tibetan_clock/bell_{a,b}_{intensity,count,center}/{config,state,set}` | bidirectional | state retained | per-bell config sliders |\n| `number/tibetan_clock/{swing_up,swing_down,cycle}/{config,state,set}` | bidirectional | state retained | global timing sliders |\n\n#### PubSubClient gotchas baked into the code\n\n- `setBufferSize(1024)` is called **before** `connect()` (calling after has no effect on outgoing publishes — [issue #764](https://github.com/knolleary/pubsubclient/issues/764)).\n- `setKeepAlive(60)` so a 12 s ring doesn't trip the default 15 s keepalive.\n- Commands are **deferred to the main loop**, not run inside the MQTT callback — ringing inside the callback would starve `client.loop()` for ~12 s.\n\n## Build / flash / monitor\n\nPlatformIO is at `/Users/gre/.platformio/penv/bin/pio`. Add it to your shell PATH:\n\n```sh\nexport PATH=\"$HOME/.platformio/penv/bin:$PATH\"\n```\n\nThen:\n\n```sh\ncd firmware\npio run                                                # build\npio run -t upload --upload-port /dev/cu.usbmodem2101  # flash\npio device monitor --port /dev/cu.usbmodem2101 --echo  # serial console\n```\n\nThe `--echo` flag enables local echo so you can see what you type. Exit with **Ctrl+C**.\n\n\u003e If `pio run -t upload` reports \"could not open port\", a serial monitor is holding the port — close it first. The C3's USB-Serial/JTAG also re-enumerates between flash stages; if a single upload fails mid-write, just rerun.\n\n### Configuration\n\n`platformio.ini` pulls secrets from `private_config.ini` via `extra_configs`. The latter is gitignored — copy from the template to set up a fresh checkout:\n\n```sh\ncp firmware/private_config.ini.template firmware/private_config.ini\n$EDITOR firmware/private_config.ini\n```\n\nDefines (all required for networking):\n\n- `WIFI_SSID`, `WIFI_PASSWORD`\n- `MQTT_HOST`, `MQTT_PORT` (default 1883)\n- `MQTT_USER`, `MQTT_PASSWORD`\n- `MQTT_CLIENT_ID`\n\nBackslash-escape internal quotes: `-DWIFI_SSID=\\\"YourSSID\\\"`.\n\nOther compile-time toggles (in `platformio.ini`):\n\n- `ENABLE_NETWORKING=0` — strips out WiFi/MQTT init for pure servo testing (M1 mode).\n\n## Serial commands\n\nThe boot is silent (no self-test ring). The serial console accepts the same kind of commands as MQTT, plus a couple of diagnostics:\n\n| Command | Action |\n|---------|--------|\n| `0` | Ring Bell A using its configured intensity / count from NVS |\n| `1` | Ring Bell B using its configured intensity / count from NVS |\n| `0 30 3` | Bell A, override to intensity 30, 3 strikes (NVS values not changed) |\n| `1 50 1` | Bell B, override to intensity 50, 1 strike |\n| `p` | Toggle MOSFET (blue LED on/off, no servo motion) — diagnostic |\n| `s 0` / `s 1` | Sweep one servo 60→120→90 without striking — diagnostic |\n| `?` | Help |\n\n## Status LEDs\n\nCentralized through `leds.{h,cpp}` so WiFi and MQTT layers don't fight over them.\n\n| State | Green | Red |\n|-------|-------|-----|\n| Booting | fast blink | fast blink |\n| Connecting WiFi | off | slow blink |\n| WiFi connected | solid | off |\n| MQTT connected | solid | off |\n| Ringing | fast blink | off |\n| Error | off | solid |\n\n## NVS layout\n\nPersistent values live under the `tibetan` Preferences namespace (`config.cpp`):\n\n| Key | Type | Range | Default (A / B) |\n|-----|------|-------|-----------------|\n| `ver` | uint16 | – | schema version (currently 8 — bumping resets all values to defaults) |\n| `int_a` / `int_b` | uint8 | 5–50° | 28 / 20 |\n| `cnt_a` / `cnt_b` | uint8 | 1–9 | 1 / 3 |\n| `ctr_a` / `ctr_b` | uint8 | 60–120° | 90 / 85 |\n| `sup` | uint8 | 0–30 ms | 20 (wind-up step) |\n| `sdn` | uint8 | 0–10 ms | 0 (release step — snap) |\n| `cyc` | uint16 | 500–10000 ms | 5000 (strike cycle) |\n\nValues are clamped on load; flash is rewritten only if clamping actually changed a value.\n\n## Home Assistant automation example\n\nOnce the device shows up in HA, you can drive it from automations like any other entity. This one rings Bell A every hour on the hour, but only while the sun is up:\n\n```yaml\nalias: Tibetan clock — hourly chime (daytime only)\ndescription: Ring Bell A every hour, skip if the sun is down\ntriggers:\n  - trigger: time_pattern\n    minutes: 0\nconditions:\n  - condition: state\n    entity_id: sun.sun\n    state: above_horizon\nactions:\n  - action: button.press\n    target:\n      entity_id: button.ring_bell_a\nmode: single\n```\n\n`button.press` sends an empty payload, so the ring uses whatever the `Bell A Intensity` / `Count` sliders are currently set to. To override per-press, publish a JSON body directly:\n\n```yaml\nactions:\n  - action: mqtt.publish\n    data:\n      topic: homeassistant/button/tibetan_clock/ring_bell_a/command\n      payload: '{\"intensity\": 25, \"count\": 1}'\n```\n\nThe exact entity_id (`button.ring_bell_a` vs `button.tibetan_clock_ring_bell_a`) depends on how HA slugified your device name — check Settings → Devices \u0026 Services → Tibetan Clock to confirm.\n\n## License\n\nGPL v3 — see [`LICENSE`](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgre%2Fring-my-tibetan-clock","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fgre%2Fring-my-tibetan-clock","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgre%2Fring-my-tibetan-clock/lists"}