Skip to content

feat: native Timer app — on-device menu, MQTT/HTTP API, Home Assistant entities, multi-device sync#830

Open
ltloopy wants to merge 4 commits into
Blueforcer:mainfrom
ltloopy:timer-upstream
Open

feat: native Timer app — on-device menu, MQTT/HTTP API, Home Assistant entities, multi-device sync#830
ltloopy wants to merge 4 commits into
Blueforcer:mainfrom
ltloopy:timer-upstream

Conversation

@ltloopy

@ltloopy ltloopy commented Jul 8, 2026

Copy link
Copy Markdown

feat: native Timer app — on-device menu, MQTT/HTTP API, Home Assistant entities, multi-device sync

What this adds

A full-featured countdown Timer as a native app, controllable via HTTP, MQTT, Home Assistiant, or On Device. Every behavior is adjustable, so the timer can adapt from a "kitchen timer" to a "lab alarm".

  • On device: start/pause/resume/reset with the middle button inside the Timer app; a TIMER drill-in menu (duration wheel, buzzer/finished modes, tuning knobs) in the onscreen menu. Over-wide menu labels now scroll (marquee), benefiting all menus.
  • HTTP: POST /api/timer + GET /api/timer observation mirror (full config readback).
  • MQTT: {prefix}/timer command topic; retained timer_state / timer_dur / timer_rem state topics.
  • Home Assistant: 10 auto-discovered entities (duration text, remaining + state sensors, buzzer + finished selects, start/pause/reset buttons, sync switch + dynamic sync-targets select), each carrying read-only JSON attribute objects so the device's full config is readable from HA.
  • Multi-device sync (optional, off by default): clocks on the same LAN mirror each other's timer over UDP broadcast (port 4212, broker-free) with per-clock send-targeting and receive-consent settings.

Docs included: new docs/timer.md, plus additions to api.md, apps.md, dev.md, onscreen.md, _sidebar.md.

Tuning knobs

All knobs persist to NVS, are settable over HTTP/MQTT (same JSON keys) and dev.json at boot (timer_* prefixed), and are readable back via GET /api/timer and the HA attributes; the knobs you'd regulary tweak; hold delays, re-alert cadence, beep window, icon/bar toggles — are also in the on-device TIMER menu:

Knob Range (default) What it does
max_duration 1–604800 s (86400) Upper bound on accepted durations — out-of-range commands are rejected, not clamped; also caps the on-device duration wheel
remaining_publish_interval 1–60 s (1) How often timer_rem republishes while running (MQTT + HA sensor cadence)
finished_hold 1–300 s (10) How long the 0:00 screen stays up in auto-clear mode (editable in the on-device TIMER menu)
realert_interval 5–300 s (15) Buzzer re-fire cadence in re-alert mode (editable in the on-device TIMER menu)
countdown_seconds 0–30 s (3) Beep window before zero in countdown buzzer mode (editable in the on-device TIMER menu)
melody_tick / melody_end name or inline RTTTL (timer_tick / timer_end) Countdown-beep and end melodies — /MELODIES/ file by name, or an inline RTTTL tune (always one-shot)
icon_idle/running/paused/finished icon name (empty) Per-state icon from /ICONS/, with idle as the fallback slot
icon_enabled bool (true) Hide the icon; time text + bar reflow to the full panel (editable in the on-device TIMER menu)
bar_enabled bool (true) Show/hide the progress bar (editable in the on-device TIMER menu)
bar_color hex (0 = text color) Progress-bar foreground color
bar_bg_color hex (0 = none) Background track drawn behind the bar

One descriptor table drives all of it — validation, apply, NVS persistence, dev.json overrides, the GET /api/timer mirror and the HA attribute projection all walk the same rows, so the write surface and every read surface can't drift apart, and adding a future knob is one table row.

New concepts this branch introduces

Three things here have no precedent in the codebase yet; they're designed as reusable patterns, not timer one-offs:

  • A dedicated on-device menu for an app. Until now the onscreen menu only held global settings; the TIMER item is the first app-owned config menu on the device. It's a drill-in list (left/right walks the items, short press opens an editor leaf, long press steps back up), reachable both from the main menu and by long-pressing inside the idle Timer app. The navigation state machine and the slot-table that drives it are display-free and generic — a future native app that wants on-device settings can reuse the same pattern with its own rows. The marquee scrolling for over-wide labels this needed applies to every menu, not just TIMER.

  • Multi-device sync (device-to-device communication). First time two AWTRIX clocks talk to each other directly: a UDP broadcast channel on port 4212, no broker involved. Clocks announce themselves with a 30 s presence beacon and learn their peers; actions propagate with a 3× redundant send + (src,seq) dedup. The model is two independent per-clock settings — whom I command (sync_targets) and whether I obey (sync_follow) — so leader/follower/mirror/standalone are all compositions of the same two switches, and no clock can be commanded without its own consent.

  • Home Assistant attributes as a config read surface. HA entities so far only carried state; here each timer entity also publishes its related persisted settings as a retained read-only JSON attribute object (json_attributes_topic), so the device's full configuration is inspectable from inside HA — no MQTT subscription or HTTP polling needed. The vendored-lib opt-in (setJsonAttributes on select/sensor/text) is generic and available to any other entity the firmware registers.

  • A writable text entity (HAText). a new text device type, free-form typed input from HA wasn't possible — entities were limited to sensors, fixed-option selects, switches and buttons. This PR adds HAText to the library (following its device-type conventions, EX_ARDUINOHA_TEXT-guarded like the other optional types) and uses it for the duration entity: type 7:30 into HA and the timer is set, the input riding the same validated parseCommand path as MQTT/HTTP — malformed or out-of-range text simply reverts to the previous value. Any future feature that wants typed input from HA gets the entity type for free.

Defaults and upgrade behavior

  • The Timer app ships enabled (like other native apps). Disable entirely via the APPS menu, dev.json show_timer:false, or /api/settings TIMER:false — this also unpublishes the HA entities (empty retained discovery payloads so HA prunes them) and disables the command surface.
  • Multi-device sync is inert out of the box: nothing is sent until a user sets sync_targets on at least one clock. sync_follow defaults on so targeting a clock "just works", but any clock can opt out.
  • All timer settings persist in their own NVS namespace; no existing settings are touched.

Vendored lib changes (lib/home-assistant-integration)

  • New HAText device type (guarded EX_ARDUINOHA_TEXT, same pattern as the library's other optional types) — used for the writable duration entity.
  • HAMqtt::publishConfigForDeviceType() — publish discovery for a single late-created entity without a reconnect (used by the dynamic peers select).
  • HASelect::resetOptions() + optional JSON-attributes topic on HASelect/HASensor.
  • getDevicesTypesNb() un-gated so the firmware can guard registration against the entity cap (which this PR raises to fit the 10 timer entities).

How to test

HTTP

# start a 10-minute timer
curl -X POST http://<clock-ip>/api/timer -H "Content-Type: application/json" \
     -d '{"duration":"10:00","action":"start"}'
# read everything back (run-state + full config mirror)
curl http://<clock-ip>/api/timer

MQTT

# same JSON, published to the command topic (no retain flag — it's a command, not state)
mosquitto_pub -h <broker> -t '<prefix>/timer' -m '{"duration":"10:00","action":"start"}'
mosquitto_pub -h <broker> -t '<prefix>/timer' -m '{"action":"pause"}'
# watch the retained state topics update
mosquitto_sub -h <broker> -v -t '<prefix>/stats/timer_state' -t '<prefix>/stats/timer_dur' -t '<prefix>/stats/timer_rem'
# validation check: out-of-range duration is silently ignored, timer_dur stays unchanged
mosquitto_pub -h <broker> -t '<prefix>/timer' -m '{"duration":"48:00:00"}'

Home Assistant (with HA_DISCOVERY=true)

  1. Open the AWTRIX device page.
  2. Type a duration into the Timer duration text entity (7:30, MM:SS, or bare seconds).
  3. Press the start/pause/reset buttons and watch the state and remaining sensors follow (remaining updates every remaining_publish_interval s).
  4. Expand any entity's attributes to see the read-only config projection — e.g. the state sensor carries the full config object; change bar_color over HTTP and watch the attribute update.
  5. Toggle buzzer/finished selects and confirm the change is visible.

On device: middle button inside the Timer app (start/pause/resume/reset), long-press for the TIMER menu.

Sync: on one of two clocks set the Timer sync targets select (or sync_targets) to All, then start a timer on it — the other clock mirrors the run; pause/reset follow. The Targets select's option list grows as peers are discovered (~30 s beacon).

Size

Stock toolchain, both device envs:

ulanzi Flash RAM
upstream main (723e8c7) 1,269,229 (96.8%) 101,600 (31.0%)
this PR 1,299,953 (99.2%) 104,856 (32.0%)
delta +30,724 (+2.4 pts) +3,256 (+1.0 pt)

(awtrix2_upgrade: 99.0% flash / 32.0% RAM.)

Flash headroom on the current partition layout was already thin on main (96.8%); this lands at 99.2%.

Compile-time opt-out: AWTRIX_DISABLE_TIMER

The Timer feature is included by default — a stock build is unchanged. For
flash-constrained setups it can be excluded entirely at compile time:

build_flags = -DAWTRIX_DISABLE_TIMER

(or PLATFORMIO_BUILD_FLAGS=-DAWTRIX_DISABLE_TIMER pio run)

A disabled build contains no Timer code at all: no Timer app, no TIMER
on-device menu entry, no /api/timer HTTP endpoint, no {prefix}/timer MQTT
topic, no Home Assistant timer entities, no multi-device sync.

Measured effect of disabling (vs. this PR's default build):

Env Flash RAM
ulanzi −29,612 B (99.2% → 96.9%) −3,200 B
awtrix2_upgrade −29,624 B (99.0% → 96.7%) −3,216 B

i.e. opting out returns the firmware to roughly its pre-Timer size.

Implementation: plain #ifndef AWTRIX_DISABLE_TIMER guards, following the
existing #ifndef awtrix2_upgrade convention — whole-file wraps on the
timer-owned sources plus call-site guards in the shared files. No
platformio.ini changes. All four combinations (both envs × flag on/off)
compile; default builds are byte-identical with the flag simply absent.

Scope / review notes

  • Firmware + docs only: no platformio.ini changes, no CI changes, no test scaffolding. Both device envs (ulanzi, awtrix2_upgrade) build at the stock toolchain settings.
  • The feature was developed and hardened on a fork with an extensive host-side unit-test suite (14 native PlatformIO test envs, ~7,000 lines of tests) and 27 architecture decision records; those stay on the fork to keep this diff reviewable: https://github.com/ltloopy/awtrix3/tree/feat-timer-standalone

ltloopy and others added 3 commits July 7, 2026 09:01
Vendored-library groundwork for the Timer app's Home Assistant surface:

- HAText: new device type (text entity with command/state topics),
  registered in ArduinoHA.h. Guarded by EX_ARDUINOHA_TEXT like the
  library's other optional types.
- HAMqtt::publishConfigForDeviceType(): publish discovery config for a
  single already-registered device type, so an entity created after the
  broker connection exists doesn't need a full reconnect.
- HAMqtt::getDevicesTypesNb() moved out of the ARDUINOHA_TEST guard so
  the firmware can guard entity registration against the device-type cap.
- HASelect::resetOptions(): release the option list so a dynamic,
  discovery-driven select can rebuild its options at runtime.
- HASelect/HASensor: optional JSON-attributes topic (json_attr_t) with
  retained publishJsonAttributes(); HADictionary entry to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…multi-device sync

A countdown Timer as a native app, driven through one validated command
surface (atomic-reject: an invalid command changes nothing):

- Timer app: per-state icons, depleting progress bar (optional background
  track + colors), blinking Finished screen with auto-clear / hold /
  re-alert modes, countdown beeps + end melody (built-in RTTTL defaults,
  /MELODIES/ files or inline RTTTL overrides).
- Buttons: start/pause/resume/reset from the Timer app; Finished alert
  clearable from any app.
- Onscreen menu: TIMER drill-in list (HH:MM:SS duration wheel, buzzer /
  finished modes, tuning knobs); over-wide menu labels scroll (marquee).
- HTTP: POST /api/timer commands, GET /api/timer observation mirror with
  full config readback; TIMER key on /api/settings.
- MQTT: {prefix}/timer command topic; retained timer_state / timer_dur /
  timer_rem state topics.
- Home Assistant: 10 discovered entities (duration text, remaining/state
  sensors, buzzer/finished selects, start/pause/reset buttons, sync
  switch + dynamic sync-targets select), each projecting its persisted
  settings as read-only JSON attributes.
- Multi-device sync (off by default): LAN clocks mirror start/pause/reset
  over UDP broadcast (port 4212, broker-free) with per-clock send
  targeting (sync_targets) and receive consent (sync_follow); config
  travels only inside a start and applies one-shot on the follower.
- One-shot runs: save:false applies a command's config to the current run
  only; every observation surface keeps reporting the saved config.
- show_timer master enable: false removes the app, unpublishes the HA
  entities (retained-empty discovery) and disables the command surface.

All timer settings persist in their own NVS namespace. No changes to
existing settings, platformio.ini, or CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New docs/timer.md (quick start, Home Assistant entities + read-only
config attributes, multi-device sync, persistence); Timer sections in
apps.md and api.md (full command/key reference); dev.json keys in
dev.md; TIMER menu row + label scrolling in onscreen.md; sidebar links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ltloopy
ltloopy marked this pull request as ready for review July 8, 2026 02:45
@eku

eku commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Can this be disabled at compile time to save flash and RAM?

@ltloopy

ltloopy commented Jul 8, 2026

Copy link
Copy Markdown
Author

Can this be disabled at compile time to save flash and RAM?

Yes this can be added. I had considered this when building it but left it out for two reasons; there is still flash/ram space available, and there are other items that can be reworked to reduce the flash/ram usage.

Building with -DAWTRIX_DISABLE_TIMER excludes the entire Timer stack
(app, on-device menu, MQTT/HTTP API, HA entities, multi-device sync):
~29.6 kB flash and ~3.2 kB RAM reclaimed (ulanzi: 99.2% -> 96.9% flash;
awtrix2_upgrade: 99.0% -> 96.7%). Default builds are unchanged. Guards
follow the existing #ifndef awtrix2_upgrade convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants