diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 359a874a..9ebca7fd 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -15,6 +15,7 @@ - Features - [Apps](apps.md) - [Native Apps](apps.md#native-apps) + - [Timer](apps.md#timer) - [Custom Apps](apps.md#custom-apps) - [Effects](effects.md) - [Icons](icons.md) @@ -33,4 +34,5 @@ - [Drawing](api.md#drawing-instructions) - [Colored textfragmentss](api.md#display-text-in-colored-fragments) - [App switching](api.md#switch-to-specific-app) + - [Timer Control](api.md#timer-control) - [Settings](api.md#change-settings) \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index 9556a83f..d858a370 100644 --- a/docs/api.md +++ b/docs/api.md @@ -20,6 +20,7 @@ + [Dismiss Notification](#dismiss-notification) + [Switch Apps](#switch-apps) + [Switch to Specific App](#switch-to-specific-app) + * [Timer Control](#timer-control) * [Change Settings](#change-settings) + [JSON Properties](#json-properties-1) * [Update](#update) @@ -354,10 +355,191 @@ Directly transition to a desired app using its name. - `Temperature` - `Humidity` - `Battery` +- `Timer` For custom apps, employ the name you designated in the topic or HTTP parameter. In MQTT, if `[PREFIX]/custom/test` is your topic, then `test` would be the app's name. +## Timer Control + +Control the built-in Timer app. See the [Timer app overview](https://blueforcer.github.io/awtrix3/#/apps?id=timer) for behaviour details. + +| MQTT Topic | HTTP URL | Payload/Body | HTTP Method | +| ---------------- | ------------------------- | ------------ | ----------- | +| `[PREFIX]/timer` | `http://[IP]/api/timer` | JSON (see below) | POST | +| – | `http://[IP]/api/timer` | – (returns JSON snapshot, see [State observation](#state-observation)) | GET | + +All JSON properties are optional. When multiple are sent together, property setters apply first and then `action` runs — so `{"duration":600,"action":"start"}` starts a fresh 10-minute timer in one request. + +#### JSON Properties + +| Key | Type | Values | Description | +| --- | --- | --- | --- | +| `action` | string | `start` / `pause` / `reset` | Performs the action. `start` from `idle` also force-switches the display to the Timer app (gated on no active game). `start` is a no-op while already running. `start` from `finished` restarts the countdown. | +| `duration` | string or integer | A clock string `"HH:MM:SS"`/`"MM:SS"`, or a bare integer of seconds. Must be 1 .. `timer_max_duration`. | Sets the configured duration. Colon count decides units (`"3:00"` = 3 min, never 3 h); out-of-range fields carry (`"3:90"` = 270 s). A malformed string **or** an out-of-range value is **rejected** (HTTP `400`, see Responses), not clamped. Published back as a trimmed clock string (`300` → `5:00`). Persists across reboots. Mid-run writes buffer until next `start`/`reset`. | +| `buzzer` | string | `off` / `end` / `countdown` | Sets buzzer mode. Persists. | +| `finished` | string | `auto-clear` / `hold` / `re-alert` | Sets what happens at `00:00`. Persists. | +| `icon_idle` | string | Bare icon name resolved against `/ICONS/.{jpg,gif}`; empty clears the slot; capped at 32 chars | Icon shown in the **Idle** state and used as fallback for any other state whose slot is empty. Persists. | +| `icon_running` | string | Same. Empty clears (then falls back to `icon_idle`). | Icon shown while counting down. Persists. | +| `icon_paused` | string | Same. Empty clears (then falls back to `icon_idle`). | Icon shown while paused. Persists. | +| `icon_finished` | string | Same. Empty clears (then falls back to `icon_idle`). | Icon shown beneath the blinking `0:00`. Persists. | +| `finished_hold` | integer | 1–300 (s) | Auto-clear delay (only meaningful when `finished = "auto-clear"`). Persists. | +| `realert_interval` | integer | 5–300 (s) | Re-alert cadence (only meaningful when `finished = "re-alert"`). Persists. | +| `countdown_seconds` | integer | 0–30 (s) | Pre-expiry beep window (only meaningful when `buzzer = "countdown"`). Persists. | +| `max_duration` | integer | 1–604800 (s, 1 s .. 7 days) | Upper bound on accepted `duration`. Persists. | +| `remaining_publish_interval` | integer | 1–60 (s) | How often `timer_rem` republishes while Running. Persists. | +| `melody_tick` | string | **Either** a bare name resolved against `/MELODIES/.txt` (≤32 chars) **or** an inline RTTTL tune | RTTTL countdown-beep melody. A bare name persists and obeys `save`; an **inline tune is always one-shot** (never saved). | +| `melody_end` | string | Same. A bare empty string resets to default `"timer_end"`. | RTTTL end melody. Bare name persists & obeys `save`; inline tune always one-shot. | +| `bar_enabled` | bool | `true` / `false` | Show/hide the Running/Paused progress bar. Persists. | +| `icon_enabled` | bool | `true` / `false` | Show/hide the timer icon; when hidden the time text and bar reflow to span the full panel. Persists. | +| `bar_color` | int or hex string | `0..0xFFFFFF` or `"#RRGGBB"` / `"RRGGBB"` | Progress-bar (foreground) color; `0` follows `TEXTCOLOR_888`. Persists. | +| `bar_bg_color` | int or hex string | `0..0xFFFFFF` or `"#RRGGBB"` / `"RRGGBB"` | Progress-bar **background track** color (drawn behind the bar); `0` = black = no track (the default — bar looks unchanged). Unlike `bar_color`'s `0`, this is a literal black, not a sentinel. Persists. | +| `sync_follow` | bool | `true` / `false` | Obey inbound multi-device timer-sync this clock is targeted by (follow consent gate). Local identity — not propagated. Persists. | +| `sync_targets` | string | `""` / `all` / comma list of peer `uniqueID`s | Whom this clock commands on a local timer action. Local identity — not propagated. Persists. | +| `save` | bool | `true` (default) / `false` | `save:false` makes the **whole command one-shot**: its config applies to the current run only and reverts to the saved settings when the timer next returns to Idle (a `reset` or auto-clear), writing nothing to flash. A non-boolean is rejected (atomic-reject). See the one-shot note below. | + +`action` / `buzzer` / `finished` values are case-insensitive; `auto-clear`/`autoclear` and `re-alert`/`realert` are both accepted. Icon and **bare** melody values are **case-sensitive** (they map to filenames on LittleFS) and **capped at 32 characters** (alphanumeric, `_`, `-`). The icon loader checks `/ICONS/.jpg` then `/ICONS/.gif`; the melody loader reads `/MELODIES/.txt`. + +**One-shot commands (`save:false`) and inline melodies**. By default every config key persists to flash and becomes your new default. Send `save:false` to run a timer **once** with custom parameters: its config applies to the current run only and reverts when the timer next returns to Idle, writing nothing to flash. While a one-shot run is active the **observation surface stays honest** — the `GET /api/timer` `config` mirror, the Home Assistant attribute entities, and device-to-device sync all keep reporting your **saved** configuration, never the one-off values (the top-level `duration`/`buzzer`/`finished` still show the live values). `melody_end`/`melody_tick` additionally accept an **inline RTTTL tune** (a literal melody string such as `"alarm:d=4,o=5,b=120:c,e,g"`, detected by its `:`-separated `d=`/`o=`/`b=` structure; a malformed tune is rejected `400`); an inline tune is **always one-shot** regardless of `save`, never persists, and **never appears** in the `config` mirror (which shows the saved bare name throughout). The on-device TIMER menu always persists. + +#### Responses + +`POST /api/timer` validates the whole command **atomically** — if any field is invalid, **nothing is applied**: + +| Status | Body | When | +| --- | --- | --- | +| `200 OK` | `OK` | Command accepted and applied. | +| `400 Bad Request` | `ErrorParsingJson` | Body isn't valid JSON (or exceeds the parse buffer). | +| `400 Bad Request` | `InvalidValue` | A field has an unusable value: malformed/out-of-range `duration`, or an unknown `buzzer`/`finished`/`action`/icon. | +| `409 Conflict` | `TimerDisabled` | The Timer feature is off (`SHOW_TIMER=false`); the command is understood but can't apply. | + +Out-of-range durations are **rejected**, not clamped (e.g. `"30:00:00"` when the max is 24 h → `400`). The `{prefix}/timer` MQTT topic applies the same validation but, being fire-and-forget, ignores invalid commands silently (no error is published); the retained `timer_dur` state reflects the unchanged value. + +#### Examples + +Start a 5-minute timer immediately (numeric seconds or the equivalent clock string): +```json +{"duration": 300, "action": "start"} +``` +```json +{"duration": "5:00", "action": "start"} +``` + +Change buzzer mode mid-run (takes effect immediately for the countdown ticks): +```json +{"buzzer": "countdown"} +``` + +Reset to idle (also clears the finished screen if visible): +```json +{"action": "reset"} +``` + +Set per-state icons (idle stays as fallback; Running uses an animated GIF): +```json +{"icon_idle": "64936", "icon_running": "74706"} +``` + +Clear an override so the slot falls back to the Idle icon: +```json +{"icon_paused": ""} +``` + +#### State observation + +`GET http://[IP]/api/timer` returns a read-only JSON snapshot of the live timer. This is an **observation** endpoint — it never mutates state and takes no body. It always responds `200 OK`; there is no `409` when the timer is disabled (the `enabled` field carries that bit instead). + +```json +{ + "state": "running", + "enabled": true, + "remaining": 174, + "remaining_str": "2:54", + "duration": 300, + "duration_str": "5:00", + "buzzer": "end", + "finished": "auto-clear", + "config": { + "finished_hold": 10, + "realert_interval": 15, + "countdown_seconds": 3, + "max_duration": 86400, + "max_duration_str": "24:00:00", + "remaining_publish_interval": 1, + "icon_enabled": true, + "bar_enabled": true, + "bar_color": "default", + "bar_bg_color": "none", + "melody_tick": "timer_tick", + "melody_end": "timer_end", + "sync_follow": true, + "sync_targets": "", + "buzzer": "end", + "finished": "auto-clear", + "icon_idle": "", + "icon_running": "", + "icon_paused": "", + "icon_finished": "" + } +} +``` + +The response has two parts: the stable top-level **run-state summary** (the eight fields below, byte-compatible with earlier firmware) and a nested **`config`** object mirroring the full persisted configuration. + +| Field | Type | Description | +| --- | --- | --- | +| `state` | string | `idle` / `running` / `paused` / `finished`. | +| `enabled` | bool | Mirrors the `TIMER` master enable (`SHOW_TIMER`). When `false` the timer is forced to `idle`; the snapshot is still returned. | +| `remaining` | integer | Seconds remaining, computed live at request time (wall-clock accurate). Frozen while `paused`; `0` while `finished`. | +| `remaining_str` | string | `remaining` as a trimmed clock string (same format as `duration_str`, e.g. `174` → `2:54`). | +| `duration` | integer | Configured duration in seconds. **Run-state, not config** — it stays top-level only and is deliberately absent from `config`. | +| `duration_str` | string | `duration` as a trimmed clock string (`300` → `5:00`, hours dropped when zero). | +| `buzzer` | string | Buzzer mode: `off` / `end` / `countdown`. Also mirrored inside `config`. | +| `finished` | string | Finished mode: `auto-clear` / `hold` / `re-alert`. Also mirrored inside `config`. | + +The `buzzer` / `finished` strings are the **canonical output spellings** (hyphenated, lowercase). The `POST` command parser additionally tolerates aliases on input (e.g. `autoclear`, `realert`), but the read endpoint always reports the canonical form. + +##### `config` — persisted configuration mirror + +The `config` object reports **every persisted Timer configuration key** — exactly the keys you can write via `POST /api/timer` — so an HTTP-only integrator can read the device's live configuration (and confirm a write applied) without Home Assistant or an MQTT subscription. It is projected from the same descriptor tables that drive the control surface and the HA attribute groups, so the read surface cannot drift from what is writable: adding a new persisted config key automatically makes it readable here. + +While a **one-shot** run (`save:false`, see above) is active, `config` reports your **saved** configuration, never the one-off values — so a read-after-write check sees the saved truth, not the transient run-only override. The top-level `duration`/`buzzer`/`finished` continue to show the live one-shot values. An inline RTTTL tune never appears here; `config.melody_*` shows the saved bare name throughout. + +Values are **raw** by default (interval seconds as integers, melodies and `sync_targets` as strings, toggles as booleans), with two deliberate carrier-native renderings that follow this endpoint's own raw+`_str` duration precedent: + +- **`bar_color`** is rendered as `"#RRGGBB"` (uppercase) or `"default"` when it follows the text color, rather than a raw 24-bit integer. +- **`bar_bg_color`** is rendered as `"#RRGGBB"` (uppercase) or `"none"` when it is black (no track), rather than a raw integer — the `"none"` vs `"default"` wording reflects the deliberate sentinel asymmetry between the two colors. +- **`max_duration`** is reported **both** raw (`86400`) **and** as `max_duration_str` (`"24:00:00"`, trimmed clock form). + +| `config` key | Type | Notes | +| --- | --- | --- | +| `finished_hold` | integer | Seconds the finished screen holds (hold mode). | +| `realert_interval` | integer | Seconds between re-alerts (re-alert mode). | +| `countdown_seconds` | integer | Length of the pre-end countdown tick window. | +| `max_duration` | integer | Maximum settable duration, raw seconds. | +| `max_duration_str` | string | `max_duration` as a trimmed clock string (carrier-native). | +| `remaining_publish_interval` | integer | Seconds between `remaining` MQTT pushes. | +| `icon_enabled` | bool | Whether per-state icons are shown. | +| `bar_enabled` | bool | Whether the progress bar is shown. | +| `bar_color` | string | `"#RRGGBB"` or `"default"` (carrier-native; not the raw integer). | +| `bar_bg_color` | string | `"#RRGGBB"` or `"none"` (carrier-native; not the raw integer). Background track behind the bar. | +| `melody_tick` | string | RTTTL melody name for countdown ticks. | +| `melody_end` | string | RTTTL melody name played at finish. | +| `sync_follow` | bool | Whether this clock follows synced peers (observable only; never HA-writable or propagated). | +| `sync_targets` | string | Multi-device sync targets: `""` (off), `all`, or a comma list of device IDs (observable only). | +| `buzzer` | string | Buzzer mode — mirrored here for completeness (also top-level). | +| `finished` | string | Finished mode — mirrored here for completeness (also top-level). | +| `icon_idle` / `icon_running` / `icon_paused` / `icon_finished` | string | The four per-state icon names (empty string = falls back to the Idle icon). | + +`config` never contains `duration` (run-state, reported top-level only) or `action` (a transient command verb, not persisted). The endpoint stays a pure observation read regardless: always `200 OK`, never mutating, no `409`. + +> **Note on freshness vs. Home Assistant:** `remaining` here is computed at the moment of the request, so during `running` it can read a second or two lower than the HA `{id}_timer_rem` sensor, which is push-throttled to `remaining_publish_interval` (default 1 s). This is expected — the polled HTTP read is simply fresher than the throttled push sensor; the two are not in disagreement. + +When `HA_DISCOVERY` is enabled, Home Assistant also receives live updates of all timer properties via MQTT discovery sensors — an alternative path for observing the timer remotely. + +The current icon configuration is also published as a retained JSON message to `[PREFIX]/timer/icons` whenever it changes (and on MQTT connect). Subscribe to that topic to read back current icon slots without HA. Payload shape: `{"idle": "...", "running": "...", "paused": "...", "finished": "..."}`. The same four icon names are now also readable over HTTP under `config.icon_*` (above) — Home Assistant deliberately has no icon read path, since it cannot usefully render an AWTRIX icon file. + + ## Change Settings Adjust various settings related to the app display. @@ -400,11 +582,12 @@ You can adjust each property in the JSON object according to your preferences. I | `HUM_COL` | string/array of ints | Text color of the humidity app. Use 0 for global text color. | RGB array or hex color | N/A | | `BAT_COL` | string/array of ints | Text color of the battery app. Use 0 for global text color. | RGB array or hex color | N/A | | `SSPEED` | integer | Scroll speed modification. | Percentage of original scroll speed | 100 | -| `TIM` | boolean | Enable or disable the native time app (requires reboot). | `true`/`false` | true | -| `DAT` | boolean | Enable or disable the native date app (requires reboot). | `true`/`false` | true | -| `HUM` | boolean | Enable or disable the native humidity app (requires reboot). | `true`/`false` | true | -| `TEMP` | boolean | Enable or disable the native temperature app (requires reboot). | `true`/`false` | true | -| `BAT` | boolean | Enable or disable the native battery app (requires reboot). | `true`/`false` | true | +| `TIM` | boolean | Enable or disable the native time app. App rotation refreshes immediately. | `true`/`false` | true | +| `DAT` | boolean | Enable or disable the native date app. App rotation refreshes immediately. | `true`/`false` | true | +| `HUM` | boolean | Enable or disable the native humidity app. App rotation refreshes immediately. | `true`/`false` | true | +| `TEMP` | boolean | Enable or disable the native temperature app. App rotation refreshes immediately. | `true`/`false` | true | +| `BAT` | boolean | Enable or disable the native battery app. App rotation refreshes immediately. | `true`/`false` | true | +| `TIMER` | boolean | Master enable for the Timer app. When `false`: app hidden, HA entities suppressed, `/api/timer` and `{prefix}/timer` ignored, running timer reset. HA entity removal completes after next MQTT reconnect. | `true`/`false` | true | | `MATP` | boolean | Enable or disable the matrix. Similar to `power` endpoint but without the animation. | `true`/`false` | true | | `VOL` | integer | Allows to set the volume of the buzzer and DFplayer. | 0–30 | true | | `OVERLAY` | string | Sets a global effect overlay (cannot be used with app specific overlays). | Varies (see below) | N/A | diff --git a/docs/apps.md b/docs/apps.md index db4f650a..8712dbae 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -110,6 +110,79 @@ Due to differences in battery batches and the degradation of the cheap battery o - **`max_battery`**: Enter the `bat_raw` value when the battery is fully charged. +--- +## Timer + +The Timer app counts down a configurable duration with on-device and remote control. It is auto-hidden from the app rotation while idle by default, so it only takes a slot in the rotation when actively counting down, paused, or finished. + +#### Display + +- 8×8 icon on the left (the built-in colour `icon_timer` hourglass, the same glyph shown in the on-device menu; override by placing an 8×8 `/ICONS/timer.jpg` in the file manager). +- Time text in the middle: `MM:SS` for durations under one hour, `H:MM` between 1h and 10h, `HH:MM` for 10h+. +- A 1-pixel depleting progress bar across the bottom row (x=9..31) reflects `remaining / duration`. +- In the `Hold` and `Re-alert` finished modes the time text blinks at ~1 Hz to signal that user action is required. + +#### States + +| State | Visible behaviour | +| --- | --- | +| `idle` | App shows the configured duration with no progress bar | +| `running` | Time counts down; progress bar drains from the left (right edge anchored at column 31) | +| `paused` | Time and bar frozen at the current value | +| `finished` | Timer app pulls itself to the foreground, wakes the display if it was asleep, and blinks `0:00` at 500 ms with no progress bar. Depending on finished mode it auto-clears, holds, or re-alerts | + +#### Physical controls + +While the Timer app is the current app: +- **SELECT short** — start (idle), pause (running), resume (paused), restart (finished). +- **SELECT long** — reset to idle at the configured duration. Clears the finished screen if shown. + +While **any** app is current AND the timer is in the `finished` state, SELECT short and long both work as above (so you can react to the end alert without navigating back to the Timer app). + +#### Buzzer modes + +| Mode | Behaviour | +| --- | --- | +| `Off` | Silent in all phases | +| `End` | Single end tone at `00:00` | +| `Countdown` | Tick tone fires once a second for the final `timer_countdown_seconds` (dev.json, default 3), then the end tone at `00:00` | + +The end tone is loaded from `/MELODIES/timer_end.txt` if present, otherwise a built-in RTTTL fallback is used. The countdown tick tone uses `/MELODIES/timer_tick.txt` with the same fallback pattern. + +#### Finished modes + +| Mode | Behaviour | +| --- | --- | +| `Auto-clear` | Finished screen disappears after `timer_finished_hold` seconds (dev.json, default 10), then state returns to `idle` | +| `Hold` | Finished screen stays with a blinking `00:00` until cleared (SELECT button, or a Start/Reset command — from HA, the Start/Reset buttons) | +| `Re-alert` | Like `Hold`, but additionally re-plays the buzzer end tone every `timer_realert_interval` seconds (dev.json, default 15) | + +#### Setting the duration on the device + +While the Timer app is the current app and the timer is `idle`, **long-press SELECT** to open the **TIMER menu** (see [onscreen menu](onscreen.md)). Duration is its first item: select the `DURATION` leaf to get the `HH:MM:SS` wheel with a 1-pixel underline beneath the field you are currently editing. + +| Gesture | Effect | +| --- | --- | +| SELECT short | Cycles the active field: HH → MM → SS → HH | +| LEFT short | Decreases the active field by 1 (wraps within the field) | +| RIGHT short | Increases the active field by 1 (wraps within the field) | +| LEFT / RIGHT hold | After ~500 ms triggers auto-repeat at ~4 ticks per second until released | +| SELECT long | Saves the duration and returns to the menu list | + +The menu has **no idle timeout** — it never auto-exits while you are editing. The dialed value is silently clamped to `[1, timer_max_duration]` on save. Each field has independent bounds (HH 0–99, MM 0–59, SS 0–59) — incrementing seconds past 59 does **not** carry over into minutes. Use SELECT short to switch to the minutes field instead. While the timer is running or paused the `DURATION` leaf is read-only. + +You can also set the duration via Home Assistant or the [native API](https://blueforcer.github.io/awtrix3/#/api?id=timer-control). + +#### Customisation + +- Sounds: drop `timer_tick.txt` and `timer_end.txt` (RTTTL strings) into `/MELODIES/` via the web file manager. +- Icon: drop an 8×8 `timer.jpg` into `/ICONS/`. +- Behaviour knobs: see the [Timer-related dev.json keys](https://blueforcer.github.io/awtrix3/#/dev). + +#### Remote control + +The timer is fully controllable from Home Assistant (when `HA_DISCOVERY` is enabled — it exposes eight entities under the device card) and from the native MQTT/HTTP API. See [Timer Control](https://blueforcer.github.io/awtrix3/#/api?id=timer-control) for the API surface. + --- # Custom Apps diff --git a/docs/dev.md b/docs/dev.md index d04b82d3..2d1ecd8d 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -40,6 +40,24 @@ The JSON object has the following properties: | `new_year` | boolean | Displays fireworks and plays a jingle at newyear. | false | | `swap_buttons` | boolean | Swaps the left and right hardware button. | false | | `ldr_on_ground` | boolean | Sets the LDR configuration to LDR-on-ground. | false | +| `show_timer` | boolean | Master enable for the [Timer app](https://blueforcer.github.io/awtrix3/#/apps?id=timer). When `false`: app removed from rotation, the 10 Timer HA entities not published, `POST /api/timer` and the `{prefix}/timer` MQTT topic ignored, any running timer reset. On the `true → false` transition the firmware publishes empty retained discovery payloads so HA prunes the stale entities. Also exposed via `/api/settings` (`TIMER` key) and the on-device **APPS** menu. | `true` | +| `timer_max_duration` | integer | Upper bound on accepted `duration` values (range 1–604800). Out-of-range duration commands are rejected, not clamped. Also caps the HH/MM/SS wheels in the Timer-app config mode. Promoted to `{prefix}/timer` / `/api/timer`. | `86400` | +| `timer_remaining_publish_interval` | integer | Seconds between `timer_rem` republishes while Running (range 1–60). Drives the HA `{id}_timer_rem` sensor cadence. | `1` | +| `timer_finished_hold` | integer | Seconds the `00:00` Finished screen stays visible in Auto-clear finished mode (range 1–300). | `10` | +| `timer_realert_interval` | integer | Seconds between buzzer re-fires in Re-alert finished mode (range 5–300). | `15` | +| `timer_countdown_seconds` | integer | Tick window (seconds before zero) in Countdown buzzer mode (range 0–30). | `3` | +| `timer_icon_idle` | string | Bare icon name (resolved against `/ICONS/.{jpg,gif}`) for the Timer app's **Idle** state. Also used as fallback for any other state with an empty slot. Empty/absent = no override of the NVS-stored value. Capped at 32 chars. **Overrides NVS on every boot.** | `""` | +| `timer_icon_running` | string | Same, for the Running state. Empty falls back to `timer_icon_idle`. | `""` | +| `timer_icon_paused` | string | Same, for the Paused state. Empty falls back to `timer_icon_idle`. | `""` | +| `timer_icon_finished` | string | Same, for the Finished (blinking `0:00`) state. Empty falls back to `timer_icon_idle`. | `""` | +| `timer_melody_tick` | string | Bare melody name (resolved against `/MELODIES/.txt`) for countdown beeps. Empty resets to default `"timer_tick"`. | `"timer_tick"` | +| `timer_melody_end` | string | Bare melody name for the end melody. Empty resets to default `"timer_end"`. | `"timer_end"` | +| `timer_bar_enabled` | boolean | Show/hide the Running/Paused progress bar. | `true` | +| `timer_icon_enabled` | boolean | Show/hide the timer icon; when hidden the time text and bar reflow to the full panel. | `true` | +| `timer_bar_color` | integer | Hex color (0..0xFFFFFF) for the progress bar (foreground). `0` follows `TEXTCOLOR_888`. | `0` | +| `timer_bar_bg_color` | integer | Hex color (0..0xFFFFFF) for the progress-bar **background track** drawn behind the bar. `0` = black = no track (literal, not a sentinel). | `0` | +| `timer_sync_follow` | boolean | When `true`, this clock obeys inbound timer-sync packets it is targeted by (multi-device sync follow consent gate). Local identity — never propagated. | `true` | +| `timer_sync_targets` | string | Whom this clock commands on a local timer action: `""` (sync off), `all`, or a comma list of peer device IDs (e.g. `awtrix_ab12,awtrix_cd34`). Local identity — never propagated. | `""` | #### Example: diff --git a/docs/onscreen.md b/docs/onscreen.md index 3d1beac9..187b7821 100644 --- a/docs/onscreen.md +++ b/docs/onscreen.md @@ -4,6 +4,8 @@ AWTRIX 3 provides a **onscreen menu** directly on your clock. Press and hold the middle button for 2 seconds to access the menu. Navigate through the items with the left and right buttons and choose the submenu with a push on the middle button. Hold down the middle button for 2s to exit the current menu and to save your setting. + +Any menu label too wide for the 32px panel **scrolls**: it holds briefly at the left so you can read the beginning, then scrolls left and loops continuously while the item stays selected (the same scroll speed as notifications and apps). Labels that fit stay centered and still. The scroll restarts from the left whenever you move to another item or re-open the menu. !> You can easily turn your AWTRIX matrix on or off by simply double-pressing the middle button if youre not in Menu. @@ -18,6 +20,7 @@ Hold down the middle button for 2s to exit the current menu and to save your set | `DATE` | Allows selection of date format. | | `WEEKDAY` | Allows selection of start of week. | | `TEMP` | Allows selection of temperature system (°C or °F). | +| `TIMER` | Configure the [Timer app](apps.md#timer) via a drill-in list: left/right walks the items (wrapping), a short press opens the highlighted item's editor, a long press steps back up. Also reachable by long-pressing the middle button from an idle Timer app (a long press out of the list then returns you there). Items: `DURATION` (HH:MM:SS wheel — short press cycles the field, left/right adjusts with hold-to-repeat, long press saves; read-only while the timer runs), `BUZZER` (Off/End/Countdown), `COUNTDOWN` (beep window, 0–30 s), `FINISH` (`CLEAR`/`HOLD`/`RE-ALERT`), `CLEAR DELAY` (1–300 s), `RE-ALERT INTERVAL` (5–300 s), `ICON` and `PROGRESS BAR` (show/hide toggles), `MAIN` (back to the main menu). Mode changes apply immediately as you scroll; everything persists when you leave the list. | | `APPS` | Allows to enable or disable internal apps | | `SOUND` | Allows to enable or disable sound output | | `UPDATE` | Check and download new firmware if available. | diff --git a/docs/timer.md b/docs/timer.md index e69de29b..5a1b7015 100644 --- a/docs/timer.md +++ b/docs/timer.md @@ -0,0 +1,183 @@ +# Timer + +AWTRIX 3 has a built-in countdown **Timer app**: set a duration, start it, and the +clock counts down with a progress bar, finishing with a melody and a blinking +`0:00` screen. It is controllable four ways, all driving the same validated +command surface: + +- **On device** — buttons inside the Timer app, plus the `TIMER` item in the + [onscreen menu](onscreen.md). +- **HTTP** — `POST /api/timer` (see the [API reference](api.md#timer-control)). +- **MQTT** — the `{prefix}/timer` command topic (same JSON as HTTP). +- **Home Assistant** — auto-discovered entities (below). + +The app's display, states, physical buttons and buzzer/finished modes are +documented in [Apps](apps.md#timer). The full command/key reference with +validation rules lives in the [API docs](api.md#timer-control); the `dev.json` +boot overrides in [dev.md](dev.md). This page covers the quick start, the Home +Assistant surface, and multi-device sync. + +The Timer ships enabled; disable it entirely with `show_timer` in `dev.json` or +the `TIMER` key on `/api/settings` (this removes the app, unpublishes the HA +entities and ignores timer commands). + +## Quick start + +Start a 10-minute timer: + +```bash +curl -X POST http://[IP]/api/timer -H "Content-Type: application/json" \ + -d '{"duration":"10:00","action":"start"}' +``` + +or publish the same JSON to `{prefix}/timer`. Pause, resume and reset: + +```json +{"action":"pause"} +{"action":"start"} +{"action":"reset"} +``` + +Durations accept `"HH:MM:SS"`, `"MM:SS"` or bare seconds (`600`). Colon count +decides the units (`"3:00"` is 3 minutes), out-of-range fields carry +(`"3:90"` = 270 s). Malformed or out-of-range input is **rejected** +(HTTP `400`; MQTT silently ignores it) — never clamped, and an invalid command +changes nothing at all (atomic reject). + +Retained state topics `{prefix}/stats/timer_state`, `…/timer_dur` and +`…/timer_rem` report the lifecycle state (`idle`/`running`/`paused`/`finished`), +the configured duration as a clock string, and the seconds remaining (published +every `remaining_publish_interval` seconds while running). + +### One-shot runs + +Add `save:false` to run a timer once with custom settings — the config applies +to that run only, writes nothing to flash, and reverts to your saved settings +when the timer returns to Idle: + +```json +{"duration":"0:30","buzzer":"end","melody_end":"beep:d=16,o=6,b=180:c,c,c","action":"start","save":false} +``` + +`melody_end`/`melody_tick` accept either a bare melody name (a file under +`/MELODIES/`) or an inline RTTTL tune as above; an inline tune is always +one-shot. While a one-shot run is active, every observation surface (the +`GET /api/timer` config mirror, the HA attributes, peer sync) keeps reporting +your **saved** configuration. Details in the [API docs](api.md#timer-control). + +## Home Assistant entities + +With `HA_DISCOVERY=true` the firmware advertises ten Timer entities: + +| Entity | Type | Purpose | +| --- | --- | --- | +| `{id}_timer_dur` | `text` | Duration as a clock string (writable; accepts `MM:SS`/bare seconds too). Invalid input reverts to the previous value. | +| `{id}_timer_rem` | `sensor` | Seconds remaining (read-only; updates every `remaining_publish_interval` s while running). | +| `{id}_timer_state` | `sensor` | `idle` / `running` / `paused` / `finished`. | +| `{id}_timer_buz` | `select` | Buzzer mode (`Off` / `End` / `Countdown`). | +| `{id}_timer_fin` | `select` | Finished mode (`Clear` / `Hold` / `Re-alert`). | +| `{id}_timer_start` | `button` | Equivalent to `{"action":"start"}`. | +| `{id}_timer_pause` | `button` | Equivalent to `{"action":"pause"}`. | +| `{id}_timer_reset` | `button` | Equivalent to `{"action":"reset"}`. | +| `{id}_timer_sync_follow` | `switch` | Multi-device sync receive consent (`sync_follow`), writable. | +| `{id}_timer_sync_targets` | `select` | Multi-device sync send targeting (`sync_targets`), writable: `Off`, `All`, plus one option per **discovered peer clock** (the list updates as peers appear/disappear). A multi-ID CSV set over HTTP/MQTT shows as unknown; the state sensor's `sync_targets` attribute stays authoritative. | + +When a timer is started from Idle the display auto-switches to the Timer app +(unless a game or a navigation-blocking app is active). + +### Read-only configuration attributes + +Each carrier entity also exposes the persisted settings it is semantically +about as a **read-only JSON attribute object**, so the device's configuration +is readable from inside HA without an MQTT/HTTP query: + +| Carrier | Attributes | +| --- | --- | +| `{id}_timer_dur` | `max_duration` (clock form, e.g. `"24:00:00"`) | +| `{id}_timer_rem` | `remaining_publish_interval` | +| `{id}_timer_buz` | `countdown_seconds`, `melody_tick`, `melody_end` | +| `{id}_timer_fin` | `realert_interval`, `finished_hold` | +| `{id}_timer_state` | the full config: `max_duration` (raw seconds), `remaining_publish_interval`, `icon_enabled`, `bar_enabled`, `bar_color` (`"default"` or `"#RRGGBB"`), `bar_bg_color` (`"none"` or `"#RRGGBB"`), `sync_follow`, `sync_targets` | + +The attribute objects are retained and republished after every config change +and MQTT (re)connect. During a one-shot run they keep reporting the saved +configuration, never the transient one-off values. + +## Multi-device sync + +Two or more AWTRIX clocks on the same LAN can mirror each other's timer: when +one starts, pauses or resets, the action propagates to a chosen set of peers. +Sync is **broker-free** — it rides UDP broadcast on port **4212**, so it works +without MQTT. + +### Roles: two independent settings + +| Setting | Axis | Meaning | +| --- | --- | --- | +| `sync_targets` | send | Whom this clock commands on a *local* action: `""` (send nothing, the default), `all`, or a comma list of peer `uniqueID`s. | +| `sync_follow` | receive | Whether this clock obeys inbound sync it is targeted by (default **on**; turn off to make a clock ignore peers). | + +Compose them freely: a **leader** (targets set, follow off) commands but never +obeys; a **follower** (follow on, no targets) obeys but never commands; +set every clock to `sync_targets=all` + `sync_follow=true` for "everyone +mirrors everyone". Out of the box nothing propagates — sync only activates +once you set `sync_targets` on at least one clock. + +### What propagates + +- **Run-state:** `start` / `pause` / `reset` propagate the action. A `start` + also carries the duration (the only duration-bearing packet) — so a bare + duration edit on one clock never moves a peer's countdown. +- **Config:** travels **only** bundled inside a `start`, as the leader's + effective settings for that run. The follower applies it **one-shot** and + reverts to its own saved settings when its timer returns to Idle — a peer's + `start` can never overwrite a clock's saved configuration. +- **Never propagated:** `sync_follow` / `sync_targets` themselves (each + clock's own identity) and the live remaining seconds. + +### Behavior notes + +- Targeting is by the stable `uniqueID` (shown in the web UI); `all` spares + you from listing ids. +- One-hop: an applied inbound command is never re-broadcast, so partial + target lists do not chain. +- Delivery is best-effort: each packet is sent 3× and receivers de-duplicate, + so a single dropped frame rarely desyncs a clock. +- Clocks announce themselves with a presence beacon every 30 s; that is what + populates the HA Targets select's dynamic peer list. +- A clock with `show_timer=false` ignores inbound sync. + +Set the two keys via `POST /api/timer` / `{prefix}/timer` +(`sync_follow`, `sync_targets`), via `dev.json` +(`timer_sync_follow`, `timer_sync_targets`), or via the two HA entities. + +## Settings and persistence + +All timer settings persist across reboots. The behaviour-tuning knobs +(`max_duration`, `remaining_publish_interval`, `finished_hold`, +`realert_interval`, `countdown_seconds`, `melody_tick`, `melody_end`, +`bar_enabled`, `icon_enabled`, `bar_color`, `bar_bg_color`, `sync_follow`, +`sync_targets`) are settable over HTTP/MQTT ([reference](api.md#timer-control)) +and readable back via `GET /api/timer` and the HA attributes; a subset is also +editable in the on-device `TIMER` menu. `dev.json` can override any of them at +boot ([reference](dev.md)). Per-state icons (`icon_idle` / `icon_running` / +`icon_paused` / `icon_finished`) resolve against `/ICONS/.{jpg,gif}`; +the two melodies against `/MELODIES/.txt`, with built-in default tunes +when the files are absent. + +## Build-time opt-out + +The Timer feature is included by default. To build a firmware without it — +reclaiming roughly 29 kB of flash and 3 kB of RAM on space-constrained +devices — add `-DAWTRIX_DISABLE_TIMER` to `build_flags` in `platformio.ini`, +or set `PLATFORMIO_BUILD_FLAGS=-DAWTRIX_DISABLE_TIMER` when invoking +`pio run`. A disabled build has no Timer app, no `TIMER` on-device menu +entry, no `/api/timer` HTTP endpoint, no `{prefix}/timer` MQTT topic, no +Home Assistant timer entities, and no multi-device sync. The runtime +`SHOW_TIMER` setting is only meaningful in default builds. + +Note when switching an already-provisioned device from a timer-enabled build +to a disabled one: the disabled firmware also lacks the HA discovery cleanup +code, so timer entities registered by the previous build linger as +"unavailable" in Home Assistant until you remove them there (or clear the +retained `homeassistant/…` discovery topics on the broker). diff --git a/lib/home-assistant-integration/src/ArduinoHA.h b/lib/home-assistant-integration/src/ArduinoHA.h index d7acb702..0db725d5 100644 --- a/lib/home-assistant-integration/src/ArduinoHA.h +++ b/lib/home-assistant-integration/src/ArduinoHA.h @@ -20,6 +20,7 @@ #include "device-types/HASensorNumber.h" #include "device-types/HASwitch.h" //#include "device-types/HATagScanner.h" +#include "device-types/HAText.h" #include "utils/HAUtils.h" #include "utils/HANumeric.h" diff --git a/lib/home-assistant-integration/src/HAMqtt.cpp b/lib/home-assistant-integration/src/HAMqtt.cpp index 7535d92f..cdb280ee 100644 --- a/lib/home-assistant-integration/src/HAMqtt.cpp +++ b/lib/home-assistant-integration/src/HAMqtt.cpp @@ -191,6 +191,14 @@ void HAMqtt::addDeviceType(HABaseDeviceType *deviceType) _devicesTypes[_devicesTypesNb++] = deviceType; } +void HAMqtt::publishConfigForDeviceType(HABaseDeviceType *deviceType) +{ + if (deviceType && isConnected()) + { + deviceType->onMqttConnected(); + } +} + bool HAMqtt::publish(const char *topic, const char *payload, bool retained) { if (!isConnected()) diff --git a/lib/home-assistant-integration/src/HAMqtt.h b/lib/home-assistant-integration/src/HAMqtt.h index 774c30f8..a57eb3eb 100644 --- a/lib/home-assistant-integration/src/HAMqtt.h +++ b/lib/home-assistant-integration/src/HAMqtt.h @@ -227,6 +227,17 @@ class HAMqtt */ void addDeviceType(HABaseDeviceType *deviceType); + /** + * Publishes the discovery configuration (and availability/subscriptions) for a single + * already-registered device type. Normally every device type's configuration is + * published by onConnectedLogic() on each (re)connect; this lets a device type that + * was created after the connection was established publish its configuration without + * forcing a full reconnect. No-op if the pointer is null or the broker is disconnected. + * + * @param deviceType Instance of the device's type to publish. + */ + void publishConfigForDeviceType(HABaseDeviceType *deviceType); + /** * Publishes the MQTT message with given topic and payload. * Message won't be published if the connection with the MQTT broker is not established. @@ -324,12 +335,18 @@ class HAMqtt */ void processMessage(const char *topic, const uint8_t *payload, uint16_t length); -#ifdef ARDUINOHA_TEST + /** + * Number of device types currently registered. Note ArduinoHA's effective + * capacity is one less than the max passed to the constructor (addDeviceType + * rejects once _devicesTypesNb + 1 >= _maxDevicesTypesNb). Used by the firmware + * to log/guard HA entity registration against the cap. + */ inline uint8_t getDevicesTypesNb() const { return _devicesTypesNb; } +#ifdef ARDUINOHA_TEST inline HABaseDeviceType **getDevicesTypes() const { return _devicesTypes; diff --git a/lib/home-assistant-integration/src/device-types/HASelect.cpp b/lib/home-assistant-integration/src/device-types/HASelect.cpp index 975f0d43..6a5c3258 100644 --- a/lib/home-assistant-integration/src/device-types/HASelect.cpp +++ b/lib/home-assistant-integration/src/device-types/HASelect.cpp @@ -11,6 +11,7 @@ HASelect::HASelect(const char* uniqueId) : _icon(nullptr), _retain(false), _optimistic(false), + _jsonAttributes(false), _commandCallback(nullptr) { @@ -71,6 +72,27 @@ void HASelect::setOptions(const char* options) } } +void HASelect::resetOptions() +{ + if (_options) { + // Mirror the destructor's option-array teardown: each option string is heap + // allocated only when there is more than one option (see setOptions). + const uint8_t optionsNb = _options->getItemsNb(); + const HASerializerArray::ItemType* options = _options->getItems(); + + if (optionsNb > 1) { + for (uint8_t i = 0; i < optionsNb; i++) { + delete options[i]; + } + } + + delete _options; + _options = nullptr; + } + + _currentState = -1; // no option selected until the new list is applied +} + bool HASelect::setState(const int8_t state, const bool force) { if (!force && _currentState == state) { @@ -91,7 +113,7 @@ void HASelect::buildSerializer() return; } - _serializer = new HASerializer(this, 10); // 10 - max properties nb + _serializer = new HASerializer(this, 11); // 11 - max properties nb (incl. json_attr_t) _serializer->set(AHATOFSTR(HANameProperty), _name); _serializer->set(AHATOFSTR(HAUniqueIdProperty), _uniqueId); _serializer->set(AHATOFSTR(HAIconProperty), _icon); @@ -121,6 +143,10 @@ void HASelect::buildSerializer() _serializer->set(HASerializer::WithAvailability); _serializer->topic(AHATOFSTR(HAStateTopic)); _serializer->topic(AHATOFSTR(HACommandTopic)); + + if (_jsonAttributes) { + _serializer->topic(AHATOFSTR(HAJsonAttributesTopic)); + } } void HASelect::onMqttConnected() @@ -176,6 +202,15 @@ bool HASelect::publishState(const int8_t state) return publishOnDataTopic(AHATOFSTR(HAStateTopic), item, true); } +bool HASelect::publishJsonAttributes(const char* json) +{ + if (!json) { + return false; + } + + return publishOnDataTopic(AHATOFSTR(HAJsonAttributesTopic), json, true); +} + uint8_t HASelect::countOptionsInString(const char* options) const { // the given string is treated as a single option if there are no semicolons diff --git a/lib/home-assistant-integration/src/device-types/HASelect.h b/lib/home-assistant-integration/src/device-types/HASelect.h index e92599be..6f78cbb8 100644 --- a/lib/home-assistant-integration/src/device-types/HASelect.h +++ b/lib/home-assistant-integration/src/device-types/HASelect.h @@ -31,10 +31,19 @@ class HASelect : public HABaseDeviceType * For example: `setOptions("Option A;Option B;Option C"); * * @param options The list of options that are separated by semicolons. - * @note The options list can be set only once. + * @note The options list can be set only once. Call resetOptions() first to + * replace an already-set list (e.g. a dynamic, discovery-driven select). */ void setOptions(const char* options); + /** + * Releases the current option list so setOptions() can be called again, and + * resets the current state to "no option selected" (-1). Useful for a select + * whose options change at runtime: call resetOptions(), setOptions(newList), + * then re-publish the discovery config so Home Assistant sees the new options. + */ + void resetOptions(); + /** * Changes state of the select and publishes MQTT message. * State represents the index of the option that was set using the setOptions method. @@ -104,6 +113,29 @@ class HASelect : public HABaseDeviceType inline void onCommand(HASELECT_CALLBACK(callback)) { _commandCallback = callback; } + /** + * Enables or disables publishing of JSON attributes for this select. + * When enabled, the discovery config advertises a `json_attributes_topic` + * (`json_attr_t`) so Home Assistant reads extra attributes from that topic, + * and publishJsonAttributes() publishes a retained JSON object onto it. + * Disabled by default; while disabled the discovery payload is unchanged. + * + * @param enabled `true` to advertise the JSON attributes topic. + */ + inline void setJsonAttributes(const bool enabled) + { _jsonAttributes = enabled; } + + /** + * Publishes the given JSON object as this select's attributes. + * The message is retained and rides the same data-topic machinery as the + * select's state, so Home Assistant repopulates the attributes after an + * HA or broker restart with no extra code. + * + * @param json A valid JSON object (e.g. `{"key":1}`). + * @returns Returns `true` if the MQTT message has been published successfully. + */ + bool publishJsonAttributes(const char* json); + #ifdef ARDUINOHA_TEST inline HASerializerArray* getOptions() const { return _options; } @@ -147,6 +179,9 @@ class HASelect : public HABaseDeviceType /// The optimistic mode of the select (`true` - enabled, `false` - disabled). bool _optimistic; + /// Whether the JSON attributes topic is advertised in the discovery config. + bool _jsonAttributes; + /// The command callback that will be called when option is changed via the HA panel. HASELECT_CALLBACK(_commandCallback); }; diff --git a/lib/home-assistant-integration/src/device-types/HASensor.cpp b/lib/home-assistant-integration/src/device-types/HASensor.cpp index 7642a30d..d877f6a6 100644 --- a/lib/home-assistant-integration/src/device-types/HASensor.cpp +++ b/lib/home-assistant-integration/src/device-types/HASensor.cpp @@ -9,7 +9,8 @@ HASensor::HASensor(const char* uniqueId) : _deviceClass(nullptr), _forceUpdate(false), _icon(nullptr), - _unitOfMeasurement(nullptr) + _unitOfMeasurement(nullptr), + _jsonAttributes(false) { } @@ -19,13 +20,22 @@ bool HASensor::setValue(const char* value) return publishOnDataTopic(AHATOFSTR(HAStateTopic), value, true); } +bool HASensor::publishJsonAttributes(const char* json) +{ + if (!json) { + return false; + } + + return publishOnDataTopic(AHATOFSTR(HAJsonAttributesTopic), json, true); +} + void HASensor::buildSerializer() { if (_serializer || !uniqueId()) { return; } - _serializer = new HASerializer(this, 9); // 9 - max properties nb + _serializer = new HASerializer(this, 10); // 10 - max properties nb (incl. json_attr_t) _serializer->set(AHATOFSTR(HANameProperty), _name); _serializer->set(AHATOFSTR(HAUniqueIdProperty), _uniqueId); _serializer->set(AHATOFSTR(HADeviceClassProperty), _deviceClass); @@ -44,6 +54,10 @@ void HASensor::buildSerializer() _serializer->set(HASerializer::WithDevice); _serializer->set(HASerializer::WithAvailability); _serializer->topic(AHATOFSTR(HAStateTopic)); + + if (_jsonAttributes) { + _serializer->topic(AHATOFSTR(HAJsonAttributesTopic)); + } } void HASensor::onMqttConnected() diff --git a/lib/home-assistant-integration/src/device-types/HASensor.h b/lib/home-assistant-integration/src/device-types/HASensor.h index 74a290e1..b81606e6 100644 --- a/lib/home-assistant-integration/src/device-types/HASensor.h +++ b/lib/home-assistant-integration/src/device-types/HASensor.h @@ -67,6 +67,29 @@ class HASensor : public HABaseDeviceType inline void setUnitOfMeasurement(const char* unitOfMeasurement) { _unitOfMeasurement = unitOfMeasurement; } + /** + * Enables or disables publishing of JSON attributes for this sensor. + * When enabled, the discovery config advertises a `json_attributes_topic` + * (`json_attr_t`) so Home Assistant reads extra attributes from that topic, + * and publishJsonAttributes() publishes a retained JSON object onto it. + * Disabled by default; while disabled the discovery payload is unchanged. + * + * @param enabled `true` to advertise the JSON attributes topic. + */ + inline void setJsonAttributes(const bool enabled) + { _jsonAttributes = enabled; } + + /** + * Publishes the given JSON object as this sensor's attributes. + * The message is retained and rides the same data-topic machinery as the + * sensor's value, so Home Assistant repopulates the attributes after an + * HA or broker restart with no extra code. + * + * @param json A valid JSON object (e.g. `{"key":1}`). + * @returns Returns `true` if the MQTT message has been published successfully. + */ + bool publishJsonAttributes(const char* json); + protected: virtual void buildSerializer() override final; virtual void onMqttConnected() override; @@ -83,6 +106,9 @@ class HASensor : public HABaseDeviceType /// The unit of measurement for the sensor. It can be nullptr. const char* _unitOfMeasurement; + + /// Whether the JSON attributes topic is advertised in the discovery config. + bool _jsonAttributes; }; #endif diff --git a/lib/home-assistant-integration/src/device-types/HAText.cpp b/lib/home-assistant-integration/src/device-types/HAText.cpp new file mode 100644 index 00000000..33e69797 --- /dev/null +++ b/lib/home-assistant-integration/src/device-types/HAText.cpp @@ -0,0 +1,92 @@ +#include "HAText.h" +#ifndef EX_ARDUINOHA_TEXT + +#include "../HAMqtt.h" +#include "../utils/HASerializer.h" + +HAText::HAText(const char* uniqueId) : + HABaseDeviceType(AHATOFSTR(HAComponentText), uniqueId), + _icon(nullptr), + _retain(false), + _messageCallback(nullptr), + _jsonAttributes(false) +{ + +} + +bool HAText::setState(const char* value, bool force) +{ + return publishOnDataTopic(AHATOFSTR(HAStateTopic), value, true); +} + +bool HAText::publishJsonAttributes(const char* json) +{ + if (!json) { + return false; + } + + return publishOnDataTopic(AHATOFSTR(HAJsonAttributesTopic), json, true); +} + +void HAText::buildSerializer() +{ + if (_serializer || !uniqueId()) { + return; + } + + _serializer = new HASerializer(this, 9); // 9 - max properties nb (incl. json_attr_t) + _serializer->set(AHATOFSTR(HANameProperty), _name); + _serializer->set(AHATOFSTR(HAUniqueIdProperty), _uniqueId); + _serializer->set(AHATOFSTR(HAIconProperty), _icon); + + if (_retain) { + _serializer->set( + AHATOFSTR(HARetainProperty), + &_retain, + HASerializer::BoolPropertyType + ); + } + + _serializer->set(HASerializer::WithDevice); + _serializer->set(HASerializer::WithAvailability); + _serializer->topic(AHATOFSTR(HAStateTopic)); + _serializer->topic(AHATOFSTR(HACommandTopic)); + + if (_jsonAttributes) { + _serializer->topic(AHATOFSTR(HAJsonAttributesTopic)); + } +} + +void HAText::onMqttConnected() +{ + if (!uniqueId()) { + return; + } + + publishConfig(); + publishAvailability(); + setState("", true); + subscribeTopic(uniqueId(), AHATOFSTR(HACommandTopic)); +} + +void HAText::onMqttMessage( + const char* topic, + const uint8_t* payload, + const uint16_t length +) +{ + if (_messageCallback && HASerializer::compareDataTopics( + topic, + uniqueId(), + AHATOFSTR(HACommandTopic) + )) { + static constexpr uint16_t kMaxMsg = 255; + char buf[kMaxMsg + 1]; + uint16_t copyLen = length > kMaxMsg ? kMaxMsg : length; + memcpy(buf, payload, copyLen); + buf[copyLen] = '\0'; + _messageCallback(buf, copyLen, this); + } +} + +#endif diff --git a/lib/home-assistant-integration/src/device-types/HAText.h b/lib/home-assistant-integration/src/device-types/HAText.h new file mode 100644 index 00000000..597564fe --- /dev/null +++ b/lib/home-assistant-integration/src/device-types/HAText.h @@ -0,0 +1,68 @@ +#ifndef AHA_HATEXT_H +#define AHA_HATEXT_H + +#include "HABaseDeviceType.h" + +#ifndef EX_ARDUINOHA_TEXT + +#define HATEXT_CALLBACK(name) void (*name)(const char* message, uint16_t length, HAText* sender) + +class HAText : public HABaseDeviceType +{ +public: + HAText(const char* uniqueId); + + inline void setIcon(const char* icon) + { _icon = icon; } + + inline void setRetain(const bool retain) + { _retain = retain; } + + inline void onMessage(HATEXT_CALLBACK(callback)) + { _messageCallback = callback; } + + bool setState(const char* value, bool force = false); + + /** + * Enables or disables publishing of JSON attributes for this text entity. + * When enabled, the discovery config advertises a `json_attributes_topic` + * (`json_attr_t`) so Home Assistant reads extra attributes from that topic, + * and publishJsonAttributes() publishes a retained JSON object onto it. + * Disabled by default; while disabled the discovery payload is unchanged. + * + * @param enabled `true` to advertise the JSON attributes topic. + */ + inline void setJsonAttributes(const bool enabled) + { _jsonAttributes = enabled; } + + /** + * Publishes the given JSON object as this text entity's attributes. + * The message is retained and rides the same data-topic machinery as the + * entity's state, so Home Assistant repopulates the attributes after an + * HA or broker restart with no extra code. + * + * @param json A valid JSON object (e.g. `{"key":1}`). + * @returns Returns `true` if the MQTT message has been published successfully. + */ + bool publishJsonAttributes(const char* json); + +protected: + virtual void buildSerializer() override; + virtual void onMqttConnected() override; + virtual void onMqttMessage( + const char* topic, + const uint8_t* payload, + const uint16_t length + ) override; + +private: + const char* _icon; + bool _retain; + HATEXT_CALLBACK(_messageCallback); + + /// Whether the JSON attributes topic is advertised in the discovery config. + bool _jsonAttributes; +}; + +#endif +#endif diff --git a/lib/home-assistant-integration/src/utils/HADictionary.cpp b/lib/home-assistant-integration/src/utils/HADictionary.cpp index 8fd8024b..61a9f259 100644 --- a/lib/home-assistant-integration/src/utils/HADictionary.cpp +++ b/lib/home-assistant-integration/src/utils/HADictionary.cpp @@ -18,6 +18,7 @@ const char HAComponentScene[] PROGMEM = {"scene"}; const char HAComponentFan[] PROGMEM = {"fan"}; const char HAComponentLight[] PROGMEM = {"light"}; const char HAComponentClimate[] PROGMEM = {"climate"}; +const char HAComponentText[] PROGMEM = {"text"}; // decorators const char HASerializerSlash[] PROGMEM = {"/"}; @@ -102,6 +103,7 @@ const char HATemperatureCommandTopic[] PROGMEM = {"temp_cmd_t"}; const char HATemperatureStateTopic[] PROGMEM = {"temp_stat_t"}; const char HARGBCommandTopic[] PROGMEM = {"rgb_cmd_t"}; const char HARGBStateTopic[] PROGMEM = {"rgb_stat_t"}; +const char HAJsonAttributesTopic[] PROGMEM = {"json_attr_t"}; // misc const char HAOnline[] PROGMEM = {"online"}; diff --git a/lib/home-assistant-integration/src/utils/HADictionary.h b/lib/home-assistant-integration/src/utils/HADictionary.h index 9ca13e94..2d1bb4ca 100644 --- a/lib/home-assistant-integration/src/utils/HADictionary.h +++ b/lib/home-assistant-integration/src/utils/HADictionary.h @@ -18,6 +18,7 @@ extern const char HAComponentScene[]; extern const char HAComponentFan[]; extern const char HAComponentLight[]; extern const char HAComponentClimate[]; +extern const char HAComponentText[]; // decorators extern const char HASerializerSlash[]; @@ -102,6 +103,7 @@ extern const char HATemperatureCommandTopic[]; extern const char HATemperatureStateTopic[]; extern const char HARGBCommandTopic[]; extern const char HARGBStateTopic[]; +extern const char HAJsonAttributesTopic[]; // misc extern const char HAOnline[]; diff --git a/src/Apps.cpp b/src/Apps.cpp index 378915e7..8cfb4aac 100644 --- a/src/Apps.cpp +++ b/src/Apps.cpp @@ -11,6 +11,12 @@ #include "MQTTManager.h" #include "Overlays.h" #include "timer.h" +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerManager.h" +#include "TimerView.h" +#endif +#include "Globals.h" +#include "DisplayManager.h" const uint8_t bigdigits_mask[12][7] = { {132, 48, 48, 48, 48, 48, 132}, // 0 @@ -413,6 +419,139 @@ void BatApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, i } #endif +#ifndef AWTRIX_DISABLE_TIMER +namespace { + // Painter-side layout (font-/draw-dependent). The view-model owns the rest + // of the timer geometry (text region, progress bar) — see src/TimerView.cpp. + constexpr int16_t kTimerTextY = 6; // text baseline row + constexpr int16_t kTimerScreenH = 8; // panel height (bottom row = H-1) + constexpr int kTimerConfigUnderlineStep = 10; // px between config fields +} + +static void drawTimerIcon(FastLED_NeoMatrix *matrix, int16_t x, int16_t y, TimerState state, GifPlayer *gifPlayer) +{ + static String cachedName = "\x01"; + static uint32_t cachedEpoch = 0; + static File icon; + static bool isGif = false; + static uint8_t currentFrame = 0; + static GifPlayer *lastPlayer = nullptr; + + const String &name = TimerManager.getIconForState(state); + + if (name != cachedName || cachedEpoch != g_littlefsMountEpoch) + { + cachedEpoch = g_littlefsMountEpoch; + cachedName = name; + if (icon) icon.close(); + isGif = false; + currentFrame = 0; + lastPlayer = nullptr; + + if (name.length() > 0) + { + const char *extensions[] = {".jpg", ".gif"}; + for (int i = 0; i < 2; i++) + { + String filePath = "/ICONS/" + name + extensions[i]; + if (LittleFS.exists(filePath)) + { + isGif = (i == 1); + icon = LittleFS.open(filePath); + break; + } + } + } + } + + if (icon) + { + if (isGif && gifPlayer != nullptr) + { + if (gifPlayer != lastPlayer) + { + icon.seek(0); + currentFrame = 0; + lastPlayer = gifPlayer; + } + gifPlayer->playGif(x, y, &icon, currentFrame); + currentFrame = gifPlayer->getFrame(); + return; + } + if (!isGif) + { + DisplayManager.drawJPG(x, y, icon); + return; + } + } + + // No configured/resolvable icon for this state: fall back to the built-in + // colour hourglass bitmap (src/icons.h), the same glyph the on-device menu + // uses for the Timer entry. Drawn at logical (x, y) via drawRGBBitmap to + // match the JPG path's coordinate convention (jpg_output in DisplayManager). + matrix->drawRGBBitmap(x, y, icon_timer, 8, 8); +} + +void TimerApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer) +{ + if (notifyFlag) + return; + + CURRENT_APP = "Timer"; + currentCustomApp = ""; + + // What to draw is decided by the display-free view-model; this function is + // its painter (font-dependent centering + the actual draws). See TimerView.h. + // Capture the live TimerManager state into an explicit snapshot once; this is + // the only place that maps manager state into the display-free view input. + const TimerSnapshot snap{ + TimerManager.getState(), TimerManager.getDuration(), TimerManager.getRemaining(), + TimerManager.getRunDuration(), TIMER_ICON_ENABLED, millis()}; + const TimerView view = TimerViewModel::compute(snap); + + // Config and Finished pin the app in the rotation while they're on screen. + if (view.screen == TimerView::Screen::Config || view.screen == TimerView::Screen::Finished) + state->ticksSinceLastStateSwitch = 0; + + DisplayManager.getInstance().resetTextColor(); + + // Icon on every screen except Config (which centers text over the full panel), + // and only when icon_enabled is on. view.showIcon folds both conditions in. + if (view.showIcon) + drawTimerIcon(matrix, x, y, TimerManager.getState(), gifPlayer); + + // Text, centered within the view's region. getTextWidth (font metrics) is + // the one display dependency that stays painter-side. + int16_t textX = view.textRegionX0; + if (view.showText) + { + textX = view.textRegionX0 + ((view.textRegionW - (int)getTextWidth(view.text, 0)) / 2); + DisplayManager.setTextColor(TEXTCOLOR_888); + DisplayManager.printText(textX + x, kTimerTextY + y, view.text, false, 0); + } + + // Config-mode field underline, offset from the centered text. + if (view.showUnderline) + { + int underlineX = textX + (view.underlineField * kTimerConfigUnderlineStep); + matrix->drawFastHLine(underlineX + x, (kTimerScreenH - 1) + y, 8, TEXTCOLOR_888); + } + + // Background track behind the bar: the full trough, painted first so + // the foreground draws over it. Persists while the bar is active (even when the + // foreground has drained to nothing). A black bar_bg_color (the default) is off + // pixels, so it is skipped -- the bar then looks as if no track existed. + if (view.showBarTrack && TIMER_BAR_ENABLED && TIMER_BAR_BG_COLOR) + matrix->drawFastHLine(view.barTrackStartX + x, (kTimerScreenH - 1) + y, + view.barTrackLen, TIMER_BAR_BG_COLOR); + + // Progress bar (right-anchored, drains from the left). + if (view.showBar && TIMER_BAR_ENABLED) + matrix->drawFastHLine(view.barStartX + x, (kTimerScreenH - 1) + y, view.barLen, + TIMER_BAR_COLOR ? TIMER_BAR_COLOR : TEXTCOLOR_888); +} +#endif // AWTRIX_DISABLE_TIMER + String replacePlaceholders(String text) { int start = 0; diff --git a/src/Apps.h b/src/Apps.h index 9e44bb9a..5b370a87 100644 --- a/src/Apps.h +++ b/src/Apps.h @@ -85,6 +85,10 @@ void HumApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, i void BatApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer); #endif +#ifndef AWTRIX_DISABLE_TIMER +void TimerApp(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer); +#endif + void ShowCustomApp(String name, FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, int16_t x, int16_t y, GifPlayer *gifPlayer); // Unattractive to have a function for every customapp which does the same, but currently still no other option found TODO diff --git a/src/Dictionary.cpp b/src/Dictionary.cpp index e48bce0e..082abb24 100644 --- a/src/Dictionary.cpp +++ b/src/Dictionary.cpp @@ -143,6 +143,7 @@ const char HAipAddrIcon[] PROGMEM = {"mdi:wifi"}; + #ifndef awtrix2_upgrade const char BatKey[] PROGMEM = {"bat"}; const char BatRawKey[] PROGMEM = {"bat_raw"}; diff --git a/src/DisplayManager.cpp b/src/DisplayManager.cpp index b151f207..f6b51bd1 100644 --- a/src/DisplayManager.cpp +++ b/src/DisplayManager.cpp @@ -5,6 +5,9 @@ #include "Globals.h" #include "PeripheryManager.h" #include "MQTTManager.h" +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerHaHost.h" +#endif #include "GifPlayer.h" #include #include "timer.h" @@ -22,6 +25,9 @@ #include #include "base64.hpp" #include "Games/GameManager.h" +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerManager.h" +#endif unsigned long lastArtnetStatusTime = 0; const int numberOfChannels = 256 * 3; @@ -1113,6 +1119,9 @@ void DisplayManager_::loadNativeApps() #ifdef ULANZI updateApp("Battery", BatApp, SHOW_BAT, 4); #endif +#ifndef AWTRIX_DISABLE_TIMER + updateApp("Timer", TimerApp, SHOW_TIMER, Apps.size()); +#endif ui->setApps(Apps); setAutoTransition(true); @@ -2059,6 +2068,7 @@ String DisplayManager_::getSettings() doc["HUM"] = SHOW_HUM; doc["TEMP"] = SHOW_TEMP; doc["BAT"] = SHOW_BAT; + doc["TIMER"] = SHOW_TIMER; doc["VOL"] = SOUND_VOLUME; doc["OVERLAY"] = getOverlayName(); String jsonString; @@ -2128,11 +2138,15 @@ void DisplayManager_::setNewSettings(const char *json) UPPERCASE_LETTERS = doc.containsKey("UPPERCASE") ? doc["UPPERCASE"].as() : UPPERCASE_LETTERS; SHOW_WEEKDAY = doc.containsKey("WD") ? doc["WD"].as() : SHOW_WEEKDAY; BLOCK_NAVIGATION = doc.containsKey("BLOCKN") ? doc["BLOCKN"].as() : BLOCK_NAVIGATION; +#ifndef AWTRIX_DISABLE_TIMER + bool prevShowTimer = SHOW_TIMER; +#endif SHOW_TIME = doc.containsKey("TIM") ? doc["TIM"].as() : SHOW_TIME; SHOW_DATE = doc.containsKey("DAT") ? doc["DAT"].as() : SHOW_DATE; SHOW_HUM = doc.containsKey("HUM") ? doc["HUM"].as() : SHOW_HUM; SHOW_TEMP = doc.containsKey("TEMP") ? doc["TEMP"].as() : SHOW_TEMP; SHOW_BAT = doc.containsKey("BAT") ? doc["BAT"].as() : SHOW_BAT; + SHOW_TIMER = doc.containsKey("TIMER") ? doc["TIMER"].as() : SHOW_TIMER; SOUND_ACTIVE = doc.containsKey("SOUND") ? doc["SOUND"].as() : SOUND_ACTIVE; if (doc.containsKey("VOL")) @@ -2251,6 +2265,22 @@ void DisplayManager_::setNewSettings(const char *json) } doc.clear(); applyAllSettings(); +#ifndef AWTRIX_DISABLE_TIMER + TimerManager.onShowTimerChange(prevShowTimer, SHOW_TIMER); + if (prevShowTimer && !SHOW_TIMER) + { + TimerHaHost.remove(); + } + else if (!prevShowTimer && SHOW_TIMER) + { + TimerHaHost.enable(); + } + if (prevShowTimer != SHOW_TIMER) + { + SHOW_TIMER_HA_PREV = SHOW_TIMER; + } +#endif + loadNativeApps(); saveSettings(); if (DEBUG_MODE) DEBUG_PRINTLN("Settings loaded"); diff --git a/src/Globals.cpp b/src/Globals.cpp index 4390b762..9de60b82 100644 --- a/src/Globals.cpp +++ b/src/Globals.cpp @@ -1,5 +1,8 @@ #include "Globals.h" #include "Preferences.h" +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerSettings.h" +#endif #include #include #include @@ -206,6 +209,25 @@ void loadDevSettings() BUTTON_CALLBACK = doc["button_callback"].as(); } + if (doc.containsKey("show_timer")) + { + SHOW_TIMER = doc["show_timer"].as(); + } + +#ifndef AWTRIX_DISABLE_TIMER + // Timer value-config keys: validated + applied per-key best-effort from the + // single TIMER_SETTINGS_DESCS table (ranges live there, once). dev.json is a + // boot override layer, so an invalid key is skipped, not atomic-rejected. + timerSettingsLoadDevJson(doc.as()); +#endif + + // Timer state icons stay member-backed (B1); their dev.json shadows + // seed the TimerManager members at setup(). + if (doc.containsKey("timer_icon_idle")) TIMER_ICON_IDLE = doc["timer_icon_idle"].as(); + if (doc.containsKey("timer_icon_running")) TIMER_ICON_RUNNING = doc["timer_icon_running"].as(); + if (doc.containsKey("timer_icon_paused")) TIMER_ICON_PAUSED = doc["timer_icon_paused"].as(); + if (doc.containsKey("timer_icon_finished")) TIMER_ICON_FINISHED = doc["timer_icon_finished"].as(); + if (doc.containsKey("color_correction")) { auto correction = doc["color_correction"]; @@ -283,6 +305,12 @@ void loadSettings() SHOW_DATE = Settings.getBool("DAT", false); SHOW_TEMP = Settings.getBool("TEMP", true); SHOW_HUM = Settings.getBool("HUM", true); + SHOW_TIMER = Settings.getBool("TIMER", true); + SHOW_TIMER_HA_PREV = Settings.getBool("TIMERPREV", true); + Settings.remove("TSTEP"); // removed timer_step feature; clean orphaned NVS key +#ifndef AWTRIX_DISABLE_TIMER + timerSettingsLoadNvs(Settings); // value-config table keys (TFHOLD..TSYNT); defaults live in TIMER_SETTINGS_DESCS +#endif MATRIX_LAYOUT = Settings.getUInt("MAT", 0); SCROLL_SPEED = Settings.getUInt("SSPEED", 100); #ifdef ULANZI @@ -333,6 +361,11 @@ void saveSettings() Settings.putBool("DAT", SHOW_DATE); Settings.putBool("TEMP", SHOW_TEMP); Settings.putBool("HUM", SHOW_HUM); + Settings.putBool("TIMER", SHOW_TIMER); + Settings.putBool("TIMERPREV", SHOW_TIMER_HA_PREV); +#ifndef AWTRIX_DISABLE_TIMER + timerSettingsSaveNvs(Settings); // value-config table keys (TFHOLD..TSYNT) +#endif Settings.putUInt("SSPEED", SCROLL_SPEED); #ifdef ULANZI Settings.putBool("BAT", SHOW_BAT); @@ -354,6 +387,7 @@ uint16_t MQTT_PORT = 1883; String MQTT_USER; String MQTT_PASS; String MQTT_PREFIX; +uint32_t g_littlefsMountEpoch = 0; bool IO_BROKER = false; bool NET_STATIC = false; bool SHOW_TIME = true; @@ -446,6 +480,17 @@ bool MOODLIGHT_MODE; long STATS_INTERVAL = 10000; bool DEBUG_MODE = true; uint8_t MIN_BRIGHTNESS = 2; +bool SHOW_TIMER = true; +bool SHOW_TIMER_HA_PREV = true; +// The 13 table-backed timer settings (max_duration, melodies, bar/sync, etc.) are +// DEFINED in TimerSettings.cpp beside the descriptor table whose storage pointers +// reference them. The extern decls stay in Globals.h. These four icon_ +// values are member-backed (owned by TimerManager's setters, not the table), so they +// stay here. +String TIMER_ICON_IDLE = ""; +String TIMER_ICON_RUNNING = ""; +String TIMER_ICON_PAUSED = ""; +String TIMER_ICON_FINISHED = ""; uint8_t MAX_BRIGHTNESS = 160; double movementFactor = 0.5; int8_t TRANS_EFFECT = 1; diff --git a/src/Globals.h b/src/Globals.h index 87b344a4..6421752d 100644 --- a/src/Globals.h +++ b/src/Globals.h @@ -150,4 +150,25 @@ extern OverlayEffect GLOBAL_OVERLAY; extern String HOSTNAME; extern int WEB_PORT; extern bool BUZ_VOL; +extern bool SHOW_TIMER; +extern bool SHOW_TIMER_HA_PREV; +extern uint32_t TIMER_MAX_DURATION; +extern uint16_t TIMER_PUBLISH_INTERVAL; +extern uint16_t TIMER_FINISHED_HOLD; +extern uint16_t TIMER_REALERT_INTERVAL; +extern uint16_t TIMER_COUNTDOWN_SECONDS; +extern String TIMER_ICON_IDLE; +extern String TIMER_ICON_RUNNING; +extern String TIMER_ICON_PAUSED; +extern String TIMER_ICON_FINISHED; +extern String TIMER_MELODY_TICK; +extern String TIMER_MELODY_END; +extern bool TIMER_BAR_ENABLED; +extern bool TIMER_ICON_ENABLED; +extern uint32_t TIMER_BAR_COLOR; +extern uint32_t TIMER_BAR_BG_COLOR; +extern bool TIMER_SYNC_FOLLOW; +extern String TIMER_SYNC_TARGETS; + +extern uint32_t g_littlefsMountEpoch; #endif // Globals_H diff --git a/src/MQTTManager.cpp b/src/MQTTManager.cpp index 6dba2c97..3e887c76 100644 --- a/src/MQTTManager.cpp +++ b/src/MQTTManager.cpp @@ -9,12 +9,20 @@ #include "PeripheryManager.h" #include "UpdateManager.h" #include "PowerManager.h" +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerManager.h" +#include "TimerHa.h" +#include "TimerHaHost.h" +#endif const uint16_t PORT = 1883; +// kMaxHAEntities (the HA entity registration cap) now lives in MQTTManager.h so +// TimerHaHost's carrier build can share it for the haRegistrationAtCap guard. + WiFiClient espClient; HADevice device; -HAMqtt mqtt(espClient, device, 26); +HAMqtt mqtt(espClient, device, kMaxHAEntities); HALight *Matrix, *Indikator1, *Indikator2, *Indikator3 = nullptr; HASelect *BriMode, *transEffect = nullptr; @@ -25,8 +33,20 @@ HASensor *battery = nullptr; #endif HASensor *temperature, *humidity, *illuminance, *uptime, *strength, *version, *ram, *curApp, *myOwnID, *ipAddr = nullptr; HABinarySensor *btnleft, *btnmid, *btnright = nullptr; +// The ten Timer HA carrier pointers and their resolved id buffers moved to +// TimerHaHost; the wire seam reaches ids via TimerHaHost.entityId(). bool connected; char matID[40], ind1ID[40], ind2ID[40], ind3ID[40], briID[40], btnAID[40], btnBID[40], btnCID[40], appID[40], tempID[40], humID[40], luxID[40], verID[40], ramID[40], upID[40], sigID[40], btnLID[40], btnMID[40], btnRID[40], transID[40], doUpdateID[40], batID[40], myID[40], sSpeed[40], effectID[40], ipAddrID[40]; +// The SHOW_TIMER reconcile bookkeeping and its pending-cleanup latch moved to +// TimerHaHost; MQTTManager now holds zero Timer-HA-specific state. + +// Forward declarations: the shared HA command callbacks are defined further down; +// the base entities in setup() wire them, and each now delegates its Timer branch +// to TimerHaHost via a leading tryHandle* guard. +void onButtonCommand(HAButton *sender); +void onSelectCommand(int8_t index, HASelect *sender); +void onSwitchCommand(bool state, HASwitch *sender); + long previousMillis_Stats; std::map mqttValues; std::vector topicsToSubscribe; @@ -86,6 +106,20 @@ void processMqttMessage(const String &strTopic, const String &payloadCopy) return; } + #ifndef AWTRIX_DISABLE_TIMER + { + size_t plen = MQTT_PREFIX.length(); + const char *t = strTopic.c_str(); + if (strTopic.length() > plen + && strncmp(t, MQTT_PREFIX.c_str(), plen) == 0 + && strcmp(t + plen, "/timer") == 0) + { + TimerManager.parseCommand(payloadCopy.c_str()); + return; + } + } + #endif + if (strTopic.equals(MQTT_PREFIX + "/sendscreen")) { MQTTManager.getInstance().publish("screen", DisplayManager.ledsAsJson().c_str()); @@ -224,6 +258,9 @@ void processMqttMessage(const String &strTopic, const String &payloadCopy) void onButtonCommand(HAButton *sender) { + #ifndef AWTRIX_DISABLE_TIMER + if (TimerHaHost.tryHandleButton(sender)) return; // Timer carrier: routed by the host + #endif if (sender == dismiss) { DisplayManager.dismissNotify(); @@ -247,6 +284,9 @@ void onButtonCommand(HAButton *sender) void onSwitchCommand(bool state, HASwitch *sender) { + #ifndef AWTRIX_DISABLE_TIMER + if (TimerHaHost.tryHandleSwitch(state, sender)) return; // Timer carrier: routed by the host + #endif AUTO_TRANSITION = state; DisplayManager.setAutoTransition(state); saveSettings(); @@ -255,6 +295,9 @@ void onSwitchCommand(bool state, HASwitch *sender) void onSelectCommand(int8_t index, HASelect *sender) { + #ifndef AWTRIX_DISABLE_TIMER + if (TimerHaHost.tryHandleSelect(sender, index)) return; // Timer carrier: routed by the host + #endif if (sender == BriMode) { switch (index) @@ -330,6 +373,9 @@ void onBrightnessCommand(uint8_t brightness, HALight *sender) DisplayManager.setBrightness(brightness); } +// onTimerDurationMessage moved to TimerHaHost: the dedicated Timer +// duration text callback is registered on the Duration carrier by the host. + void onNumberCommand(HANumeric number, HANumber *sender) { if (!number.isSet()) @@ -379,6 +425,17 @@ void onMqttConnected() if (DEBUG_MODE) DEBUG_PRINTLN(F("MQTT Connected")); + + #ifndef AWTRIX_DISABLE_TIMER + // Command topics must never carry a retained payload. A retained + // {prefix}/timer command (e.g. {"action":"start"}) is re-delivered by the + // broker on every (re)connect and would auto-start the timer on boot, which + // breaks the "a reboot returns the device to Idle" contract (docs/timer.md). + // PubSubClient doesn't surface the retain flag to the receive callback, so we + // purge the retained command at the source: clear it before subscribing. + mqtt.publish((MQTT_PREFIX + "/timer").c_str(), "", true); + #endif + const char *topics[] PROGMEM = { "/brightness", "/notify/dismiss", @@ -403,7 +460,11 @@ void onMqttConnected() "/sound", "/rtttl", "/sendscreen", - "/r2d2"}; + "/r2d2", + #ifndef AWTRIX_DISABLE_TIMER + "/timer", + #endif + }; for (const char *topic : topics) { if (DEBUG_MODE) @@ -424,6 +485,12 @@ void onMqttConnected() { myOwnID->setValue(MQTT_PREFIX.c_str()); version->setValue(VERSION); + + #ifndef AWTRIX_DISABLE_TIMER + // onConnected() also flushes the pending discovery cleanup reconcile() latched + // when SHOW_TIMER went off across a reboot. + TimerHaHost.onConnected(); // Timer wire + attribute groups when SHOW_TIMER + #endif } MQTTManager.publish("stats/effects", DisplayManager.getEffectNames().c_str()); @@ -729,6 +796,14 @@ void MQTTManager_::setup() ipAddr = new HASensor(ipAddrID); ipAddr->setName(HAipAddrName); ipAddr->setIcon(HAipAddrIcon); + + #ifndef AWTRIX_DISABLE_TIMER + // Resolve the Timer carrier ids and (when SHOW_TIMER) construct/register the + // carriers — at the same sequence point as before (after the device's own + // entities register), so the ArduinoHA registration/entity-cap drop order is + // unchanged. The carrier lifecycle now lives in TimerHaHost. + TimerHaHost.setup(); + #endif } else { @@ -753,6 +828,55 @@ void MQTTManager_::tick() } } +#ifndef AWTRIX_DISABLE_TIMER +// The Timer wire seam: publishes the exact (topic, payload) the +// caller hands over — retained, like the HASensor::setValue path it replaces. +// Gated on the Timer HA carriers existing (TimerHaHost.carriersReady() mirrors the +// old timerDuration creation-sentinel check), which preserves the per-entity +// null-check behaviour of the retired one-liner publish methods; mqtt.publish +// itself no-ops while disconnected, as beginPublish did before. +void MQTTManager_::publishTimerWire(const char *topic, const char *payload) +{ + if (!TimerHaHost.carriersReady()) return; + mqtt.publish(topic, payload, true); +} + +// Canonical full data topic for a Timer HA entity slot, from the same inputs +// ArduinoHA's HASerializer::generateDataTopic uses: the data prefix installed +// in setup() (MQTT_PREFIX), the device unique id, and the entity id buffer +// resolved through formatTimerHaEntityId (owned by TimerHaHost, read via +// entityId()). Byte-identity is pinned by test W1. +String MQTTManager_::timerWireTopic(TimerHaEntity slot) +{ + const char *deviceUniqueId = device.getUniqueId(); + if (!deviceUniqueId) return String(); + char topic[160]; + formatTimerHaDataTopic(MQTT_PREFIX.c_str(), deviceUniqueId, TimerHaHost.entityId(slot), topic, sizeof(topic)); + return String(topic); +} + +// A carrier entity's JSON-attributes topic (generalizing the formerly +// finished-only topic): same inputs as timerWireTopic but the json_attr_t suffix, +// so it matches the topic the carrier's HASelect advertised in discovery via +// setJsonAttributes. The retained attribute value rides the wire seam to here. +String MQTTManager_::timerWireAttrTopic(TimerHaEntity slot) +{ + const char *deviceUniqueId = device.getUniqueId(); + if (!deviceUniqueId) return String(); + char topic[160]; + formatTimerHaAttrTopic(MQTT_PREFIX.c_str(), deviceUniqueId, + TimerHaHost.entityId(slot), topic, sizeof(topic)); + return String(topic); +} + +// Canonical topic for the aggregate icons JSON — the one published +// Timer topic that is NOT an HA entity data topic. +String MQTTManager_::timerIconsTopic() +{ + return MQTT_PREFIX + "/timer/icons"; +} +#endif // AWTRIX_DISABLE_TIMER + void MQTTManager_::publish(const char *topic, const char *payload) { char result[100]; diff --git a/src/MQTTManager.h b/src/MQTTManager.h index 4ac9c4fe..4c0cc15a 100644 --- a/src/MQTTManager.h +++ b/src/MQTTManager.h @@ -4,6 +4,21 @@ #include #include +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerHa.h" +#endif + +// HA entity registration cap. ArduinoHA's HAMqtt::addDeviceType has an off-by-one +// (`_devicesTypesNb + 1 >= _maxDevicesTypesNb`), so the EFFECTIVE capacity is +// kMaxHAEntities - 1 (= 39 here). Inventory: 25 base entities (incl. battery on +// ulanzi) + 10 Timer entities (TIMER_HA_DESCRIPTOR_COUNT) = 35, leaving 4 spare +// slots. Raised from 34: at 34 the effective cap of 33 silently +// dropped the two Timer sync-control entities (the last to register). No clean +// compile-time guard — the base count is build-flag conditional — so the runtime +// guard is the DEBUG_MODE warning in TimerHaHost's carrier build via haRegistrationAtCap(). +// Shared with TimerHaHost (the Timer carrier build reads it for that guard). +constexpr uint8_t kMaxHAEntities = 40; + class MQTTManager_ { private: @@ -27,7 +42,22 @@ class MQTTManager_ bool isConnected(); String getValueForTopic(const String &topic); + #ifndef AWTRIX_DISABLE_TIMER + // The Timer wire seam: the single chokepoint through + // which Timer MQTT output flows as (topic, payload) strings, published + // retained (like the HA setValue path it replaces). timerWireTopic() sources + // an entity's canonical data topic — byte-identical to what ArduinoHA emits. + void publishTimerWire(const char *topic, const char *payload); + String timerWireTopic(TimerHaEntity slot); + String timerWireAttrTopic(TimerHaEntity slot); + String timerIconsTopic(); + #endif }; + +#ifndef AWTRIX_DISABLE_TIMER +void reconcileTimerHAState(); +#endif + extern MQTTManager_ &MQTTManager; #endif \ No newline at end of file diff --git a/src/MatrixDisplayUi.cpp b/src/MatrixDisplayUi.cpp index 0c460ae4..258d2021 100644 --- a/src/MatrixDisplayUi.cpp +++ b/src/MatrixDisplayUi.cpp @@ -188,7 +188,7 @@ int8_t MatrixDisplayUi::update() if (timeBudget <= 0) { // Implement frame skipping to ensure time budget is kept - if (this->setAutoTransition && this->state.lastUpdate != 0) + if (this->state.lastUpdate != 0) this->state.ticksSinceLastStateSwitch += ceil(-timeBudget / this->updateInterval); this->state.lastUpdate = appStart; diff --git a/src/MenuManager.cpp b/src/MenuManager.cpp index 6544eb7f..23910182 100644 --- a/src/MenuManager.cpp +++ b/src/MenuManager.cpp @@ -5,8 +5,19 @@ #include #include #include "timer.h" +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerManager.h" +#include "TimerMenu.h" +#include "TimerMenuNav.h" +#include "TimerConfigEditor.h" +#endif +#include "MQTTManager.h" +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerHaHost.h" +#endif #include #include +#include "Functions.h" // getTextWidth (centering the duration leaf + its underline) enum MenuState { @@ -20,6 +31,9 @@ enum MenuState DateFormatMenu, WeekdayMenu, TempMenu, +#ifndef AWTRIX_DISABLE_TIMER + TimerConfigMenu, +#endif Appmenu, SoundMenu, VolumeMenu, @@ -37,6 +51,9 @@ const char *menuItems[] PROGMEM = { "DATE", "WEEKDAY", "TEMP", +#ifndef AWTRIX_DISABLE_TIMER + "TIMER", +#endif "APPS", "SOUND", "VOLUME", @@ -73,7 +90,53 @@ int8_t dateFormatIndex; uint8_t dateFormatCount = 9; int8_t appsIndex; +#ifndef AWTRIX_DISABLE_TIMER +#ifndef awtrix2_upgrade +uint8_t appsCount = 6; +#else uint8_t appsCount = 5; +#endif +#else +#ifndef awtrix2_upgrade +uint8_t appsCount = 5; +#else +uint8_t appsCount = 4; +#endif +#endif + +#ifndef AWTRIX_DISABLE_TIMER +uint8_t timerConfigCount = TIMER_MENU_SLOT_COUNT; +// The TIMER menu's drill-in navigation state machine. MAIN +// is the last slot (a Navigation row); the device keeps only drawing + the commit. +TimerMenuNav timerNav(TIMER_MENU_SLOT_COUNT, TIMER_MENU_SLOT_COUNT - 1); + +// The DURATION leaf reuses the existing display-free duration edit engine, +// a menu-owned instance. The editor has no auto-apply timeout; the menu only +// drives its hold-to-repeat, and the edited duration commits via setDuration on +// leaf back-out (run-state, not the list -> main config batch). +TimerConfigEditor timerDurationEditor; + +// Is the cursor on the DURATION row? +static bool timerNavOnDuration() +{ + return TIMER_MENU_SLOTS[timerNav.index()].kind == TimerMenuKind::Duration; +} + +// The one-shot TIMER-menu commit: a single PersistBatch window (enum edits +// deferred during scroll in "timer" ns + table-row knob/toggle keys in "awtrix" +// ns), then the HA attribute republish. Fires once on the list -> main-menu +// transition (long-press out of the list, or selecting MAIN). A config edit +// never propagates to peers on its own (run-scoped config mirror): config +// travels only bundled with a `start`. +static void commitTimerMenu() +{ + { + TimerManager_::PersistBatch batch(TimerManager); + batch.markTableDirty(); + } + TimerManager.publishAllAttributeGroups(); +} +#endif MenuState currentState = MainMenu; @@ -192,6 +255,15 @@ String MenuManager_::menutext() case 4: DisplayManager.drawBMP(0, 0, icon_1486, 8, 8); return SHOW_BAT ? "ON" : "OFF"; +#endif +#ifndef AWTRIX_DISABLE_TIMER +#ifndef awtrix2_upgrade + case 5: +#else + case 4: +#endif + DisplayManager.drawBMP(0, 0, icon_timer, 8, 8); + return SHOW_TIMER ? "ON" : "OFF"; #endif default: break; @@ -206,6 +278,45 @@ String MenuManager_::menutext() { return String(SOUND_VOLUME); } +#ifndef AWTRIX_DISABLE_TIMER + case TimerConfigMenu: + // List focus: walk the named items (indicator over the list). Leaf focus: + // show the bare value only, no indicator. + if (timerNav.focus() == TimerNavFocus::List) + { + DisplayManager.drawMenuIndicator(timerNav.index(), timerConfigCount, 0xFBC000); + return timerMenuName(timerNav.index()); + } + // DURATION leaf: HH:MM:SS wheel with the active-field underline when + // editable; the static value (no underline) when read-only. + if (timerNavOnDuration()) + { + if (timerDurationEditor.isActive()) + { + // Drive the editor's hold-to-repeat from the raw button reads each + // frame. The menu is timeout-free and the editor no longer has an + // auto-apply timeout, so there is nothing else to handle. + EasyButton *bL = PeripheryManager.buttonL; + EasyButton *bR = PeripheryManager.buttonR; + TimerConfigEditor::ButtonState buttons{bL && bL->isPressed(), + bR && bR->isPressed()}; + timerDurationEditor.tick(millis(), buttons); + + snprintf(t, sizeof(t), "%02u:%02u:%02u", + (unsigned)timerDurationEditor.hh(), + (unsigned)timerDurationEditor.mm(), + (unsigned)timerDurationEditor.ss()); + // Active-field underline, aligned under the centered HH:MM:SS (the + // same step/geometry the Timer-app config screen uses). + int16_t textX = (32 - (int)getTextWidth(t, 2)) / 2; + int16_t ux = textX + timerDurationEditor.field() * 10; + DisplayManager.drawLine(ux, 7, ux + 7, 7, TEXTCOLOR_888); + return String(t); + } + return timerMenuValue(timerNav.index()); // read-only: current value + } + return timerMenuValue(timerNav.index()); +#endif default: break; } @@ -267,6 +378,19 @@ void MenuManager_::rightButton() else SOUND_VOLUME++; break; +#ifndef AWTRIX_DISABLE_TIMER + case TimerConfigMenu: + { + // List focus walks the list; a value leaf steps its value live; the + // DURATION leaf steps the active H/M/S field; a read-only leaf is a no-op. + TimerNavOutcome o = timerNav.navigate(+1); + if (o == TimerNavOutcome::AdjustValue) + timerMenuAdjust(timerNav.index(), +1); + else if (o == TimerNavOutcome::AdjustField) + timerDurationEditor.adjust(+1); + break; + } +#endif default: break; } @@ -328,6 +452,18 @@ void MenuManager_::leftButton() SOUND_VOLUME = 30; else SOUND_VOLUME--; + break; +#ifndef AWTRIX_DISABLE_TIMER + case TimerConfigMenu: + { + TimerNavOutcome o = timerNav.navigate(-1); + if (o == TimerNavOutcome::AdjustValue) + timerMenuAdjust(timerNav.index(), -1); + else if (o == TimerNavOutcome::AdjustField) + timerDurationEditor.adjust(-1); + break; + } +#endif default: break; } @@ -362,6 +498,13 @@ void MenuManager_::selectButton() UpdateManager.updateFirmware(); } break; +#ifndef AWTRIX_DISABLE_TIMER + case TimerConfigMenu: + // Open the TIMER menu at the top of the list (origin = main menu). + timerNav.enter(TIMER_MENU_SLOT_COUNT, TIMER_MENU_SLOT_COUNT - 1, + TimerNavOrigin::Menu); + break; +#endif } break; case BrightnessMenu: @@ -372,6 +515,31 @@ void MenuManager_::selectButton() DisplayManager.setBrightness(BRIGHTNESS); } break; +#ifndef AWTRIX_DISABLE_TIMER + case TimerConfigMenu: + // Short press. + if (timerNav.focus() == TimerNavFocus::List) + { + // Drill in (passing how the target leaf behaves), commit + leave on MAIN. + TimerNavLeaf leaf = timerMenuLeafKind(timerNav.index(), TimerManager.getState()); + TimerNavOutcome o = timerNav.select(leaf); + if (o == TimerNavOutcome::GoToMainMenu) + { + commitTimerMenu(); + currentState = MainMenu; + } + else if (o == TimerNavOutcome::EnterLeaf && leaf == TimerNavLeaf::DurationEditable) + { + timerDurationEditor.enter(TimerManager.getDuration()); + } + } + else if (timerNav.select() == TimerNavOutcome::CycleField) + { + // DURATION leaf: cycle H -> M -> S (value leaves just confirm back). + timerDurationEditor.cycleField(); + } + break; +#endif case Appmenu: switch (appsIndex) { @@ -391,6 +559,23 @@ void MenuManager_::selectButton() case 4: SHOW_BAT = !SHOW_BAT; break; +#endif +#ifndef AWTRIX_DISABLE_TIMER +#ifndef awtrix2_upgrade + case 5: +#else + case 4: +#endif + { + bool prev = SHOW_TIMER; + SHOW_TIMER = !SHOW_TIMER; + TimerManager.onShowTimerChange(prev, SHOW_TIMER); + if (prev && !SHOW_TIMER) + TimerHaHost.remove(); + else if (!prev && SHOW_TIMER) + TimerHaHost.enable(); + break; + } #endif default: break; @@ -455,6 +640,37 @@ void MenuManager_::selectButtonLong() PeripheryManager.setVolume(SOUND_VOLUME); saveSettings(); break; +#ifndef AWTRIX_DISABLE_TIMER + case TimerConfigMenu: + { + // Long press: in a value/read-only leaf it just steps back up to the + // list (value already live in RAM, no commit). In the DURATION leaf it + // commits the edited duration (run-state, separate from the config + // batch) then returns to the list. Out of the list it is the single + // commit seam: main menu (origin = menu) or back to the Timer app + // (origin = app). + TimerNavOutcome o = timerNav.back(); + if (o == TimerNavOutcome::CommitDuration) + { + // The normal set-duration commit path. A bare duration edit + // propagates NOTHING: duration rides only with a start, so an + // on-device length edit no longer moves a follower's displayed time. + TimerManager.setDuration(timerDurationEditor.exit()); + return; // stay in the TIMER menu, list focus + } + if (o == TimerNavOutcome::BackToList) + return; // stay in the TIMER menu, list focus + commitTimerMenu(); // GoToMainMenu / ExitMenu: commit once + if (o == TimerNavOutcome::ExitMenu) + { + // Entered from the Timer app: close the menu so the app reappears. + inMenu = false; + currentState = MainMenu; + return; + } + break; // GoToMainMenu: falls through to MainMenu + } +#endif default: break; } @@ -465,3 +681,14 @@ void MenuManager_::selectButtonLong() inMenu = true; } } + +#ifndef AWTRIX_DISABLE_TIMER +void MenuManager_::openTimerMenuFromApp() +{ + // Open the TIMER menu directly at the top of the list, origin = App so a + // long-press out of the list returns to the Timer app. + inMenu = true; + currentState = TimerConfigMenu; + timerNav.enter(TIMER_MENU_SLOT_COUNT, TIMER_MENU_SLOT_COUNT - 1, TimerNavOrigin::App); +} +#endif diff --git a/src/MenuManager.h b/src/MenuManager.h index 8e058bb4..1c3b04a0 100644 --- a/src/MenuManager.h +++ b/src/MenuManager.h @@ -17,6 +17,13 @@ class MenuManager_ void leftButton(); void selectButton(); void selectButtonLong(); + +#ifndef AWTRIX_DISABLE_TIMER + // Open the TIMER menu directly from the Timer app's idle long-press: + // list focus, first item, origin = App so a long-press out of the + // list returns to the Timer app rather than the main menu. + void openTimerMenuFromApp(); +#endif }; extern MenuManager_ &MenuManager; diff --git a/src/Overlays.cpp b/src/Overlays.cpp index b36b03d8..4307e6ca 100644 --- a/src/Overlays.cpp +++ b/src/Overlays.cpp @@ -24,14 +24,66 @@ void StatusOverlay(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, GifPl void MenuOverlay(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, GifPlayer *gifPlayer) { + // Marquee state for over-wide menu labels. The position resets to the + // left whenever the displayed string changes (navigating to another item, or a + // leaf value ticking under the cursor) and on menu (re)entry, so the readable + // hold always plays from the start and a label is never caught mid-scroll. + // This is frame-stateful device drawing, deliberately kept inline here. + static String lastText; + static float scrollPos = 0; // x of the label's left edge while scrolling + static int scrollDelay = 0; // pre-roll hold counter (frames), as notifications + static bool wasInMenu = false; if (!MenuManager.inMenu) + { + wasInMenu = false; return; - + } matrix->fillScreen(0); DisplayManager.setTextColor(0xFFFFFF); - DisplayManager.printText(0, 6, utf8ascii(MenuManager.menutext()).c_str(), true, 2); + + String text = utf8ascii(MenuManager.menutext()); + float textWidth = getTextWidth(text.c_str(), 2); + + // Fits the 32px panel: center it and hold still -- byte-for-byte today's path. + if (textWidth <= 32) + { + lastText = text; + wasInMenu = true; + DisplayManager.printText(0, 6, text.c_str(), true, 2); + return; + } + + // Over-wide: marquee. Restart from the left on (re)entry or a label change. + if (!wasInMenu || text != lastText) + { + lastText = text; + scrollPos = 0; + scrollDelay = 0; + } + wasInMenu = true; + + // Hold briefly at the left (mirrors the notification overlay's pre-roll gate), + // then scroll left at the device's shared scroll speed, reusing movementFactor + // for frame-rate independence (the same math three other call sites use). Loops + // continuously while the item stays selected. + if (scrollDelay > MATRIX_FPS * 1.2) + { + scrollPos -= movementFactor * ((float)SCROLL_SPEED / 100); + if (scrollPos + textWidth <= 0) // fully off the left edge -> loop from left + { + scrollPos = 0; + scrollDelay = 0; + } + } + else + { + ++scrollDelay; + scrollPos = 0; + } + + DisplayManager.printText((int16_t)scrollPos, 6, text.c_str(), false, 2); } void NotifyOverlay(FastLED_NeoMatrix *matrix, MatrixDisplayUiState *state, GifPlayer *gifPlayer) diff --git a/src/PeerRegistry.cpp b/src/PeerRegistry.cpp new file mode 100644 index 00000000..ea364c13 --- /dev/null +++ b/src/PeerRegistry.cpp @@ -0,0 +1,87 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "PeerRegistry.h" + +// A bounded set of {uniqueID, lastSeen} learned from inbound presence beacons; +// the backend the dynamic HA Targets select consumes. Own id is never +// stored; entries age out past kPeerTtlMs. + +void PeerRegistry::record(const String &src, unsigned long nowMs) +{ + if (src.length() == 0 || src == _ownId) return; // never store our own id + unsigned long seen = (nowMs == 0) ? 1 : nowMs; // 0 doubles as "never" + + // Refresh an existing entry. + for (uint8_t i = 0; i < _count; ++i) + { + if (_peers[i].uniqueID == src) + { + _peers[i].lastSeen = seen; + return; + } + } + + // New peer: append while bounded; otherwise overwrite the stalest slot so a busy + // LAN keeps the freshest peers rather than rejecting all new ones once full. + if (_count < kPeerMax) + { + _peers[_count].uniqueID = src; + _peers[_count].lastSeen = seen; + ++_count; + return; + } + uint8_t oldest = 0; + for (uint8_t i = 1; i < _count; ++i) + if (_peers[i].lastSeen < _peers[oldest].lastSeen) oldest = i; + _peers[oldest].uniqueID = src; + _peers[oldest].lastSeen = seen; +} + +void PeerRegistry::prune(unsigned long nowMs) +{ + uint8_t w = 0; + for (uint8_t i = 0; i < _count; ++i) + { + if ((nowMs - _peers[i].lastSeen) <= kPeerTtlMs) + { + if (w != i) _peers[w] = _peers[i]; + ++w; + } + } + for (uint8_t i = w; i < _count; ++i) _peers[i].uniqueID = String(); + _count = w; +} + +bool PeerRegistry::has(const String &id) const +{ + for (uint8_t i = 0; i < _count; ++i) + if (_peers[i].uniqueID == id) return true; + return false; +} + +size_t PeerRegistry::ids(String *out, size_t cap) const +{ + // Sort the FULL set first, then copy the lowest `cap`, so a small buffer still + // gets a stable prefix of the sorted list (not just the first-discovered ids). + // The registry is tiny (kPeerMax ~16) so an O(n^2) selection sort on a local + // copy is cheaper than dragging in and stays allocation-light. + String sorted[kPeerMax]; + for (uint8_t i = 0; i < _count; ++i) sorted[i] = _peers[i].uniqueID; + for (uint8_t i = 0; i + 1 < _count; ++i) + { + uint8_t lo = i; + for (uint8_t j = i + 1; j < _count; ++j) + if (sorted[j] < sorted[lo]) lo = j; + if (lo != i) { String t = sorted[i]; sorted[i] = sorted[lo]; sorted[lo] = t; } + } + + size_t n = 0; + for (uint8_t i = 0; i < _count && n < cap; ++i) out[n++] = sorted[i]; + return n; +} + +void PeerRegistry::clear() +{ + for (uint8_t i = 0; i < _count; ++i) _peers[i].uniqueID = String(); + _count = 0; +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/PeerRegistry.h b/src/PeerRegistry.h new file mode 100644 index 00000000..713ff076 --- /dev/null +++ b/src/PeerRegistry.h @@ -0,0 +1,59 @@ +#ifndef PeerRegistry_h +#define PeerRegistry_h + +#include + +// Peer presence / peer registry (extracted). +// +// The set of *other clocks currently on the LAN*, keyed by the stable uniqueID +// (the targeting key; the mutable hostname is unsuitable). Pure RAM/LAN-derived +// state: bounded, the clock's own id excluded, entries age out after the TTL. +// Harvested UNGATED from inbound presence beacons by TimerManager (presence is +// informational, not a command) and consumed by the dynamic HA Targets select. +// +// This module is the *set* only — record / age / query. It deliberately knows +// nothing about beacon cadence, the UDP transport, or AP mode; that scheduling + +// I/O stays on TimerManager (one place owns "when do I announce myself"). The +// methods take an injected `nowMs` and the own-id is injected via setOwnId(), so +// there is no global read — the whole contract is testable in isolation, +// without standing up the TimerManager singleton. +class PeerRegistry +{ +public: + // The clock's own uniqueID, so record() can drop our own echoed beacons. + // Set once from TimerManager::setup(); cheap to call again on reboot. + void setOwnId(const String &id) { _ownId = id; } + + // Add a peer or refresh its lastSeen. Our own id and the full-registry case + // are guarded: when full, the stalest slot is overwritten so a busy LAN keeps + // the freshest peers rather than rejecting all new ones. + void record(const String &src, unsigned long nowMs); + + // Drop entries not seen within kPeerTtlMs (~3 missed beacons). + void prune(unsigned long nowMs); + + // Is `id` currently a known peer? + bool has(const String &id) const; + + // Copy up to `cap` current peer uniqueIDs into out[], SORTED ascending, and + // return the count written (<= min(count(), cap)). The sort makes a consumer's + // option list stable regardless of discovery order. Never writes past cap. + size_t ids(String *out, size_t cap) const; + + int count() const { return _count; } + + // Forget every peer (a reboot knows no peers; the set is repopulated by inbound + // beacons). Own id is retained. + void clear(); + +private: + struct Peer { String uniqueID; unsigned long lastSeen = 0; }; + static constexpr uint8_t kPeerMax = 16; + static constexpr unsigned long kPeerTtlMs = 100000; // ~3 missed beacons + + Peer _peers[kPeerMax]; + uint8_t _count = 0; + String _ownId; +}; + +#endif diff --git a/src/PeripheryManager.cpp b/src/PeripheryManager.cpp index 3371231d..697d223b 100644 --- a/src/PeripheryManager.cpp +++ b/src/PeripheryManager.cpp @@ -18,6 +18,10 @@ #include #include #include +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerManager.h" +#include "TimerCommand.h" // TimerCommand::Action for the runStateAction seam +#endif const int buzzerPin = 2; // Buzzer an GPIO2 const int baudRate = 50; // Nachrichtenübertragungsrate const char *message = "HELLO"; // Die Nachricht, die gesendet werden soll @@ -174,6 +178,24 @@ void select_button_pressed() if (DFPLAYER_ACTIVE) PeripheryManager.playFromFile(DFMINI_MP3_CLICK); +#ifndef AWTRIX_DISABLE_TIMER + if (!MenuManager.inMenu) + { + TimerState ts = TimerManager.getState(); + if (ts == TimerState::Finished) + { + TimerManager.runStateAction(TimerCommand::Action::Reset); + return; + } + if (CURRENT_APP == "Timer") + { + if (ts == TimerState::Running) { TimerManager.runStateAction(TimerCommand::Action::Pause); } + else { TimerManager.runStateAction(TimerCommand::Action::Start); } + return; + } + } +#endif + DisplayManager.selectButton(); MenuManager.selectButton(); if (DEBUG_MODE) @@ -206,6 +228,29 @@ void select_button_pressed_long() } else if (!BLOCK_NAVIGATION) { +#ifndef AWTRIX_DISABLE_TIMER + if (!MenuManager.inMenu) + { + TimerState ts = TimerManager.getState(); + if (ts == TimerState::Finished) + { + TimerManager.runStateAction(TimerCommand::Action::Start); + return; + } + if (CURRENT_APP == "Timer" && ts == TimerState::Idle) + { + // Open the TIMER menu (origin = App) instead of the bare duration + // wheel; the wheel is now reachable only as the DURATION leaf. + MenuManager.openTimerMenuFromApp(); + return; + } + if (CURRENT_APP == "Timer") + { + TimerManager.runStateAction(TimerCommand::Action::Reset); + return; + } + } +#endif MenuManager.selectButtonLong(); DisplayManager.selectButtonLong(); if (DEBUG_MODE) @@ -323,6 +368,38 @@ const char *PeripheryManager_::playRTTTLString(String rtttl) return nullptr; // RTTTL not supported with DFPlayer } +String PeripheryManager_::resolveRtttl(const String &name, const char *fallback) +{ + File root = LittleFS.open("/MELODIES"); + if (root && root.isDirectory()) + { + File file = root.openNextFile(); + while (file) + { + if (!file.isDirectory()) + { + String fn = file.name(); + int slash = fn.lastIndexOf('/'); + int dot = fn.lastIndexOf('.'); + int end = dot > slash ? dot : fn.length(); + String base = fn.substring(slash + 1, end); + if (base == name) + { + String s; + s.reserve(file.size()); + while (file.available()) s += (char)file.read(); + file.close(); + s.trim(); + if (s.length() > 0) return s; + break; // matched but empty -> fall through to fallback + } + } + file = root.openNextFile(); + } + } + return fallback ? String(fallback) : String(); +} + const char *PeripheryManager_::playFromFile(String file) { if (!SOUND_ACTIVE) @@ -344,19 +421,10 @@ const char *PeripheryManager_::playFromFile(String file) { if (DEBUG_MODE) DEBUG_PRINTLN(F("Playing RTTTL sound file")); - if (LittleFS.exists("/MELODIES/" + String(file) + ".txt")) - { - static char melodyName[64]; - Melody melody = MelodyFactory.loadRtttlFile("/MELODIES/" + String(file) + ".txt"); - player.playAsync(melody); - strncpy(melodyName, melody.getTitle().c_str(), sizeof(melodyName)); - melodyName[sizeof(melodyName) - 1] = '\0'; - return melodyName; - } - else - { + String rtttl = resolveRtttl(file); + if (rtttl.length() == 0) return NULL; - } + return playRTTTLString(rtttl); } } diff --git a/src/PeripheryManager.h b/src/PeripheryManager.h index b43dd3a8..c0b972b5 100644 --- a/src/PeripheryManager.h +++ b/src/PeripheryManager.h @@ -37,6 +37,7 @@ class PeripheryManager_ void tick(); void playBootSound(); const char *playFromFile(String file); + String resolveRtttl(const String &name, const char *fallback = nullptr); const char *playRTTTLString(String rtttl); bool parseSound(const char *json); bool isPlaying(); diff --git a/src/ServerManager.cpp b/src/ServerManager.cpp index 0da6d87f..24d45af3 100644 --- a/src/ServerManager.cpp +++ b/src/ServerManager.cpp @@ -14,6 +14,9 @@ #include #include #include "Games/GameManager.h" +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerManager.h" +#endif #include WiFiUDP udp; @@ -21,6 +24,15 @@ WiFiUDP udp; unsigned int localUdpPort = 4210; char incomingPacket[255]; +// Propagation surface: dedicated UDP socket for device-to-device timer sync. A +// separate port (and buffer) from discovery so a full config snapshot fits and the +// FIND_AWTRIX traffic is never parsed as JSON. +#ifndef AWTRIX_DISABLE_TIMER +WiFiUDP syncUdp; +const uint16_t kTimerSyncPort = 4212; +char syncBuffer[1024]; +#endif + // Pufferdefinition #define BUFFER_SIZE 64 char dataBuffer[BUFFER_SIZE]; @@ -57,6 +69,7 @@ void ServerManager_::erase() memset(&conf, 0, sizeof(conf)); // Set all the bytes in the structure to 0 esp_wifi_set_config(WIFI_IF_STA, &conf); LittleFS.format(); + g_littlefsMountEpoch++; delay(200); formatSettings(); delay(200); @@ -115,6 +128,21 @@ void addHandler() }else{ mws.webserver->send(500, F("text/plain"), F("ErrorParsingJson")); } }); +#ifndef AWTRIX_DISABLE_TIMER + mws.addHandler("/api/timer", HTTP_POST, []() + { + switch (TimerManager.parseCommand(mws.webserver->arg("plain").c_str())) + { + case TimerCmdResult::Ok: mws.webserver->send(200, F("text/plain"), F("OK")); break; + case TimerCmdResult::Disabled: mws.webserver->send(409, F("text/plain"), F("TimerDisabled")); break; + case TimerCmdResult::BadJson: mws.webserver->send(400, F("text/plain"), F("ErrorParsingJson")); break; + case TimerCmdResult::BadField: mws.webserver->send(400, F("text/plain"), F("InvalidValue")); break; + } + }); + // Observation surface (read-only): always 200; `enabled` carries SHOW_TIMER. + mws.addHandler("/api/timer", HTTP_GET, []() + { mws.webserver->send(200, F("application/json"), TimerManager.getStateJson().c_str()); }); +#endif mws.addHandler("/api/nextapp", HTTP_ANY, []() { DisplayManager.nextApp(); mws.webserver->send(200,F("text/plain"),F("OK")); }); mws.addHandler("/fullscreen", HTTP_GET, []() @@ -250,6 +278,9 @@ void ServerManager_::setup() mws.addHandler("/save", HTTP_POST, saveHandler); addHandler(); udp.begin(localUdpPort); +#ifndef AWTRIX_DISABLE_TIMER + syncUdp.begin(kTimerSyncPort); +#endif if (DEBUG_MODE) DEBUG_PRINTLN(F("Webserver loaded")); } @@ -308,6 +339,21 @@ void ServerManager_::tick() udp.endPacket(); } } + +#ifndef AWTRIX_DISABLE_TIMER + // Propagation surface: inbound timer-sync packets (echo/follow/target/dedup + // gating happens inside applySyncCommand). + int syncSize = syncUdp.parsePacket(); + if (syncSize > 0) + { + int len = syncUdp.read(syncBuffer, sizeof(syncBuffer) - 1); + if (len > 0) + { + syncBuffer[len] = 0; + TimerManager.applySyncCommand(syncBuffer); + } + } +#endif } if (!currentClient || !currentClient.connected()) { @@ -348,6 +394,24 @@ void ServerManager_::sendTCP(String message) } } +#ifndef AWTRIX_DISABLE_TIMER +void ServerManager_::sendTimerSync(const String &payload) +{ + if (AP_MODE) return; + IPAddress bcast = WiFi.broadcastIP(); + if ((uint32_t)bcast == 0) bcast = IPAddress(255, 255, 255, 255); + // Redundant best-effort send; receivers dedup by (src,seq). Spacing rides out + // transient congestion without acks/per-target state. + for (uint8_t i = 0; i < 3; ++i) + { + syncUdp.beginPacket(bcast, kTimerSyncPort); + syncUdp.write((const uint8_t *)payload.c_str(), payload.length()); + syncUdp.endPacket(); + if (i < 2) delay(15); + } +} +#endif + void ServerManager_::loadSettings() { if (LittleFS.exists("/DoNotTouch.json")) diff --git a/src/ServerManager.h b/src/ServerManager.h index 8b6be341..ee448d6a 100644 --- a/src/ServerManager.h +++ b/src/ServerManager.h @@ -16,6 +16,11 @@ class ServerManager_ void sendButton(byte btn, bool state); void erase(); void sendTCP(String message); +#ifndef AWTRIX_DISABLE_TIMER + // Broadcast a timer-sync packet on the LAN (propagation surface). Sent 3x for + // best-effort delivery; receivers dedup by (src,seq). + void sendTimerSync(const String &payload); +#endif bool isConnected; IPAddress myIP; }; diff --git a/src/SyncEnvelope.cpp b/src/SyncEnvelope.cpp new file mode 100644 index 00000000..13d2be4f --- /dev/null +++ b/src/SyncEnvelope.cpp @@ -0,0 +1,81 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "SyncEnvelope.h" + +// The Timer-sync wire envelope + pure inbound receive gate. See SyncEnvelope.h. +// Extracted from TimerManager. + +namespace SyncEnvelope +{ + bool targetsMe(JsonVariantConst tgt, const String &ownId) + { + // A string tgt is the broadcast spelling: only the literal "all" covers us. + if (tgt.is()) + { + String s = tgt.as(); + s.trim(); + return s == "all"; + } + // An array tgt is an explicit id list: membership by uniqueID. + if (tgt.is()) + { + for (JsonVariantConst v : tgt.as()) + { + String id = v.as(); + id.trim(); + if (id == ownId) return true; + } + } + return false; + } + + Decision classify(JsonVariantConst packet, const Context &ctx) + { + JsonVariantConst sync = packet["_sync"]; + if (sync.isNull()) return {Decision::Ignore, String(), 0}; // not a sync packet + + String src = sync["src"].as(); + if (src.length() == 0 || src == ctx.ownId) + return {Decision::Ignore, String(), 0}; // malformed / own echo + + // Presence beacon: harvest the sender's uniqueID UNGATED — + // presence is informational, not a command, so it bypasses the follow/target + // gate. It carries no action/duration/config; short-circuit here. + if (packet["presence"].as()) + return {Decision::HarvestPresence, src, 0}; + + if (!ctx.follow) return {Decision::Ignore, String(), 0}; // consent gate + if (!targetsMe(sync["tgt"], ctx.ownId)) + return {Decision::Ignore, String(), 0}; // not addressed here + + return {Decision::Apply, src, sync["seq"].as()}; + } + + void build(JsonObject sync, const String &ownId, uint32_t seq) + { + sync["src"] = ownId; + sync["seq"] = seq; + } + + void build(JsonObject sync, const String &ownId, uint32_t seq, const String &targets) + { + build(sync, ownId, seq); + + String t = targets; + t.trim(); + if (t == "all") { sync["tgt"] = "all"; return; } + + JsonArray arr = sync.createNestedArray("tgt"); + int start = 0; + const int n = t.length(); + while (start < n) + { + int comma = t.indexOf(',', start); + if (comma < 0) comma = n; + String id = t.substring(start, comma); + id.trim(); + if (id.length() > 0) arr.add(id); + start = comma + 1; + } + } +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/SyncEnvelope.h b/src/SyncEnvelope.h new file mode 100644 index 00000000..9969b534 --- /dev/null +++ b/src/SyncEnvelope.h @@ -0,0 +1,77 @@ +#ifndef SyncEnvelope_h +#define SyncEnvelope_h + +#include +#include + +// Timer-sync wire envelope + inbound receive gate (extracted from +// TimerManager — the third such cut, after PeerRegistry and SyncSeenCache). +// +// Two halves, both stateless (free functions in a namespace — there is no set to +// own, unlike the two class siblings): +// +// * classify() — the PURE receive policy. Given a parsed packet plus this +// clock's {ownId, follow}, it returns the verdict: Ignore | HarvestPresence | +// Apply. It reads no globals, no clock, and no dedup cache. The four gates it +// owns (echo-drop / presence / follow consent / target match) mirror the old +// inline gate in applySyncCommand exactly. The DEDUP step deliberately stays +// OUT of classify: SyncSeenCache::seen() is test-and-record (stateful), so it +// cannot live in a pure function — TimerManager runs it as the single stateful +// guard before re-entry. +// +// * build() — the send-side envelope constructor. The bare overload writes +// {src,seq} (the presence beacon); the targeted overload also parses the +// CSV / "all" target list into tgt. seq is INJECTED (TimerManager owns the +// monotonic _syncSeq counter), so this half is stateless too. +// +// classify's context is {ownId, follow} ONLY — never this clock's own target +// list. A follower obeys on the SENDER's tgt + its OWN follow consent (the +// two-axis sync-roles invariant); the receiver's own send-target +// list has no role here. That list belongs to build() (the send axis). +// +// NOTE the wire-gate targetsMe() here is a DIFFERENT concept from TimerHa's +// timerSyncTargets* cluster, which maps the HA Targets *select* (Off/All/peer-id +// <-> option index). Same words, opposite surfaces; kept in separate files. +namespace SyncEnvelope +{ + struct Context + { + String ownId; // this clock's uniqueID (echo-drop + targetsMe) + bool follow; // this clock's receive-consent axis (TIMER_SYNC_FOLLOW) + }; + + struct Decision + { + enum Kind + { + Ignore, // drop the packet, do nothing + HarvestPresence, // record `src` into the peer registry (ungated) + Apply // re-enter parseCommand under _remoteApply (after dedup) + } kind; + + String src; // set for HarvestPresence + Apply + uint32_t seq; // set for Apply (the dedup key, with src) + }; + + // PURE receive policy. Order mirrors the old applySyncCommand gate: + // _sync null -> Ignore (not a sync packet) + // src empty / == ownId -> Ignore (malformed / own echo) + // presence == true -> HarvestPresence{src} (ungated, bypasses follow/target) + // !ctx.follow -> Ignore (consent gate) + // !targetsMe(tgt,ownId) -> Ignore (not addressed to this clock) + // else -> Apply{src, seq} + Decision classify(JsonVariantConst packet, const Context &ctx); + + // Does this packet's _sync.tgt cover ownId? Either the literal "all" string, + // or membership in the tgt id array. Anything else is false. + bool targetsMe(JsonVariantConst tgt, const String &ownId); + + // Send-side envelope construction into `sync` (a created "_sync" object). + // Bare: just {src, seq} — the presence beacon. seq is injected. + void build(JsonObject sync, const String &ownId, uint32_t seq); + // Targeted: also writes tgt from the CSV/"all" target list. "all" -> "all" + // (string); a CSV -> a tgt id array (empty/whitespace ids skipped). + void build(JsonObject sync, const String &ownId, uint32_t seq, const String &targets); +} + +#endif diff --git a/src/SyncSeenCache.cpp b/src/SyncSeenCache.cpp new file mode 100644 index 00000000..9e770a69 --- /dev/null +++ b/src/SyncSeenCache.cpp @@ -0,0 +1,39 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "SyncSeenCache.h" + +// A bounded (src,seq,atMs) ring learned from inbound sync commands; the backend +// for "apply each of the 3x redundant sends exactly once". Entries age out past +// kTtlMs (so a sender reboot — seq restart — self-clears), and the existing entry +// is NOT refreshed on a hit (first-seen ages out; the dedup semantic). + +bool SyncSeenCache::seen(const String &src, uint32_t seq, unsigned long nowMs) +{ + // A live, matching entry => redundant copy. Do NOT refresh it: first-seen ages + // out (opposite PeerRegistry's keep-alive). atMs==0 marks an empty/never slot. + for (uint8_t i = 0; i < kMax; ++i) + { + if (_entries[i].atMs != 0 && (nowMs - _entries[i].atMs) <= kTtlMs + && _entries[i].seq == seq && _entries[i].src == src) + return true; + } + + // Record at the FIFO ring cursor (round-robin eviction at the bound). nowMs==0 + // would collide with the empty-slot sentinel, so it folds to 1. + _entries[_writeIdx].src = src; + _entries[_writeIdx].seq = seq; + _entries[_writeIdx].atMs = (nowMs == 0) ? 1 : nowMs; + _writeIdx = (uint8_t)((_writeIdx + 1) % kMax); + return false; +} + +void SyncSeenCache::clear() +{ + for (uint8_t i = 0; i < kMax; ++i) + { + _entries[i].src = String(); + _entries[i].seq = 0; + _entries[i].atMs = 0; + } + _writeIdx = 0; +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/SyncSeenCache.h b/src/SyncSeenCache.h new file mode 100644 index 00000000..104da33b --- /dev/null +++ b/src/SyncSeenCache.h @@ -0,0 +1,45 @@ +#ifndef SyncSeenCache_h +#define SyncSeenCache_h + +#include + +// Timer-sync dedup set (extracted from TimerManager as a deliberate +// sibling of PeerRegistry). +// +// The set of *recently-applied sync commands*, keyed by (src, seq): the backing +// store for "apply each of the 3x redundant sends exactly once". Pure RAM, +// bounded, entries age out after the TTL — the same shape as PeerRegistry, but +// deliberately NOT merged under a shared generic (two 40-line classes are +// cheaper than one generic). It is simpler than PeerRegistry: there is no own-id (own echoes are +// dropped earlier in applySyncCommand), no sorted export, and no separate +// consumer query — the one call site test-and-records in a single atomic op. +// +// This module is the *set* only — check + record + age. It deliberately knows +// nothing about the UDP transport or the parseCommand re-entry; that I/O stays on +// TimerManager (the cut-line mirrors PeerRegistry's: the algorithm leaves, the +// transport stays). The method takes an injected `nowMs`, so there is no global +// read — the whole contract is testable in isolation, without standing up +// the TimerManager singleton. +class SyncSeenCache +{ +public: + // Test-and-record in one atomic op (there is exactly one call site, so a + // separate check is pointless and racy). Returns true when (src,seq) was seen + // within the TTL — a redundant copy the caller must drop — WITHOUT refreshing + // the existing entry (first-seen ages out; the dedup semantic, opposite + // PeerRegistry's keep-alive refresh). Otherwise records it and returns false. + bool seen(const String &src, uint32_t seq, unsigned long nowMs); + + // Forget every entry (a reboot has applied nothing yet; pure RAM). + void clear(); + +private: + struct Entry { String src; uint32_t seq = 0; unsigned long atMs = 0; }; + static constexpr uint8_t kMax = 8; + static constexpr unsigned long kTtlMs = 2000; + + Entry _entries[kMax]; + uint8_t _writeIdx = 0; // FIFO ring write cursor (round-robin eviction) +}; + +#endif diff --git a/src/SyncTargetsDebounce.cpp b/src/SyncTargetsDebounce.cpp new file mode 100644 index 00000000..1bf1bda5 --- /dev/null +++ b/src/SyncTargetsDebounce.cpp @@ -0,0 +1,44 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "SyncTargetsDebounce.h" + +// The settle-window state machine for the dynamic Targets select's republish +// decision. The transition table lives in the header; the effects +// live in TimerHaHost. + +SyncTargetsDebounce::Action SyncTargetsDebounce::step(const char *currentOpts, unsigned long nowMs) +{ + // Row 1: no change against the published signature. Clearing the dirty flag + // even mid-window is the revert-cancel semantic. + if (_sig == currentOpts) + { + _dirty = false; + return Action::Unchanged; + } + // Row 2: first sighting of the change — open the settle window. sinceMs stays + // anchored here for the whole window (row 3 never rewrites it), so mid-window + // drift does not push the republish out. + if (!_dirty) + { + _dirty = true; + _sinceMs = nowMs; + return Action::StartWindow; + } + + // Row 3: inside the window — the change has not settled yet. cur is compared + // to the PUBLISHED signature (row 1 above), never the first-sighted value. + if ((nowMs - _sinceMs) < kDebounceMs) + return Action::Waiting; + + // Row 4: the change held for the whole window — adopt it in the same atomic + // step, so the caller's publish and this signature cannot drift apart. + _sig = currentOpts; + _dirty = false; + return Action::Republish; +} + +void SyncTargetsDebounce::seed(const char *opts) +{ + _sig = opts; + _dirty = false; +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/SyncTargetsDebounce.h b/src/SyncTargetsDebounce.h new file mode 100644 index 00000000..72650996 --- /dev/null +++ b/src/SyncTargetsDebounce.h @@ -0,0 +1,58 @@ +#ifndef SyncTargetsDebounce_h +#define SyncTargetsDebounce_h + +#include + +// The Targets-select republish debounce decision, carved out +// of TimerHaHost::refreshTargets as a stateful, isolated module — the +// SyncSeenCache sibling shape: injected `nowMs`, no globals, no clock, no +// ArduinoHA. A peer-membership change must hold for the settle window before the +// Targets select's options are republished, so a burst of beacon churn yields one +// republish; this class is that decision ONLY. The effects (rebuild options, publish +// discovery config, re-apply state) and the shell guards (carrier nullptr, MQTT +// connected) stay in TimerHaHost, BEFORE step() — so window state ages across +// MQTT-down gaps and an expired window republishes on the first connected tick. +// +// Transition table (byte-for-byte the TimerHaHost debounce it was carved +// from). State: sig (last +// adopted options signature), dirty (settle window open), sinceMs (window start). +// Input per step: cur (current options), now. +// +// | # | Condition | Next state | Action | +// |---|-----------------------------------------|-------------------------------|-------------| +// | 1 | cur == sig | dirty := false | Unchanged | +// | 2 | cur != sig, !dirty | dirty := true; sinceMs := now | StartWindow | +// | 3 | cur != sig, dirty, now - sinceMs < 3000 | (none) | Waiting | +// | 4 | cur != sig, dirty, now - sinceMs >= 3000| sig := cur; dirty := false | Republish | +// +// Consequences the table encodes (each pinned by a test): +// - Row 1 mid-window is revert-cancel: membership flipping back to the published +// set cancels the pending republish. +// - Row 3 compares cur to the PUBLISHED sig, never the first-sighted value, so +// mid-window drift does NOT restart the window: A→B@t0, →C@t0+2s ⇒ one +// Republish at t0+3s adopting C. +// - seed() adopts and closes the window (no spurious first republish). +// - (now - sinceMs) is unsigned wrap arithmetic, safe across millis() rollover. +// +// Consumed by TimerHaHost: refreshTargets() steps it, createCarriers() seeds it. +class SyncTargetsDebounce +{ +public: + enum class Action { Unchanged, StartWindow, Waiting, Republish }; + + // Atomic step: compare currentOpts to the last-adopted signature and + // advance the settle window. Adopts the signature internally on Republish. + Action step(const char *currentOpts, unsigned long nowMs); + + // Baseline at carrier creation: adopt opts, close the window + // (no spurious first republish). + void seed(const char *opts); + +private: + String _sig; + bool _dirty = false; + unsigned long _sinceMs = 0; + static constexpr unsigned long kDebounceMs = 3000; +}; + +#endif diff --git a/src/TimerCommand.cpp b/src/TimerCommand.cpp new file mode 100644 index 00000000..56e1be50 --- /dev/null +++ b/src/TimerCommand.cpp @@ -0,0 +1,128 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerCommand.h" + +#include // strcmp + +// The pure Timer command plan. See TimerCommand.h. Lifted verbatim +// (behaviour-identical) from TimerManager::parseCommand's validation pass; the +// shell now drives apply from the returned Plan instead of re-deriving these flags. + +namespace TimerCommand +{ + Plan classify(JsonObjectConst packet, const Context &ctx) + { + Plan p; // ok defaults false; arrays default-zeroed + + // -- Table settings: validate + coerce each present row into staging. Nothing is + // kept until every field below validates too (atomic-reject). + // max_duration is range-defining for duration: capture its staged value so a + // payload that raises the ceiling AND sets a duration within it in the same + // call is accepted atomically. melody_end/melody_tick + // accept an inline RTTTL tune (detected by content): validated here, staged as + // RAM, and the table row excluded from the bare-name store (always one-shot). + uint32_t effectiveMaxDuration = ctx.savedMaxDuration; + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + p.tablePresent[i] = packet.containsKey(d.cmdKey); + if (!p.tablePresent[i]) continue; + + bool isMelodyKey = (strcmp(d.cmdKey, "melody_end") == 0 || strcmp(d.cmdKey, "melody_tick") == 0); + if (isMelodyKey) + { + String mv = packet[d.cmdKey].as(); + if (timerMelodyIsInline(mv)) + { + if (!timerMelodyValidateInline(mv)) return p; // !ok + if (strcmp(d.cmdKey, "melody_end") == 0) { p.inlineEnd = mv; p.haveInlineEnd = true; } + else { p.inlineTick = mv; p.haveInlineTick = true; } + p.tablePresent[i] = false; // excluded from the bare-name parse/store/snapshot + continue; + } + } + + if (!timerSettingParse(d, packet[d.cmdKey], p.tableStaged[i])) return p; // !ok + if (strcmp(d.cmdKey, "max_duration") == 0) effectiveMaxDuration = p.tableStaged[i].num; + } + + // -- duration (member-backed, B1): validated against the effective ceiling above. + p.haveDuration = packet.containsKey("duration"); + if (p.haveDuration) + { + JsonVariantConst dv = packet["duration"]; + if (dv.is()) + { + if (!timerParseHMS(dv.as(), p.durationSec)) return p; // !ok + } + else if (dv.is() || dv.is()) // any JSON number; not bool/object/null + { + p.durationSec = dv.as(); + } + else + { + return p; // !ok + } + if (p.durationSec < 1 || (effectiveMaxDuration > 0 && p.durationSec > effectiveMaxDuration)) + return p; // !ok + } + + // -- Member-backed config half (B1): validate + coerce each present row via its + // pure validator. A bad member field rejects the whole command. + for (size_t i = 0; i < TIMER_MEMBER_VALIDATOR_COUNT; ++i) + { + const TimerMemberValidatorDesc &d = TIMER_MEMBER_VALIDATORS[i]; + p.memberPresent[i] = packet.containsKey(d.cmdKey); + if (!p.memberPresent[i]) continue; + if (!d.validate(packet[d.cmdKey], p.memberStaged[i])) return p; // !ok + } + + // -- action (run-state verb). + if (packet.containsKey("action")) + { + String a = packet["action"].as(); + if (!timerIsValidAction(a)) return p; // !ok + a.toLowerCase(); + p.action = (a == "start") ? Action::Start + : (a == "pause") ? Action::Pause + : Action::Reset; + } + + // -- save flag: payload-level bool, default true. A + // non-boolean rejects the whole command (atomic-reject). + bool saveFlag = true; + if (packet.containsKey("save")) + { + JsonVariantConst sv = packet["save"]; + if (!sv.is()) return p; // !ok + saveFlag = sv.as(); + } + + // -- one-shot decision. An inline melody is always one-shot; a + // remote-applied command is ALWAYS one-shot regardless of the leader's save. + p.oneShot = !saveFlag || p.haveInlineEnd || p.haveInlineTick || ctx.remoteApply; + + // -- configInCommand: a config-block key present (table inSnapshot half OR the + // member-backed half). sync_* (inSnapshot=false) and action/duration + // (run-state) are excluded. Drives rebaseline + config broadcast. + bool snapshotChanged = false; + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + if (p.tablePresent[i] && TIMER_SETTINGS_DESCS[i].inSnapshot) snapshotChanged = true; + bool memberTouched = false; + for (size_t i = 0; i < TIMER_MEMBER_VALIDATOR_COUNT; ++i) + if (p.memberPresent[i]) memberTouched = true; + p.configInCommand = snapshotChanged || memberTouched; + + // -- HA attribute carriers dirtied by this command (any mapped key present). + // Table-driven, fires for any present key including sync_* (State carrier), + // exactly as the old apply loop. The shell republishes these iff !oneShot. + for (size_t i = 0; i < TIMER_ATTR_GROUP_DESC_COUNT; ++i) + { + const TimerAttrGroupDesc &g = TIMER_ATTR_GROUP_DESCS[i]; + if (packet.containsKey(g.cmdKey)) p.attrCarrierDirty[(size_t)g.carrier] = true; + } + + p.ok = true; + return p; + } +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerCommand.h b/src/TimerCommand.h new file mode 100644 index 00000000..1d502928 --- /dev/null +++ b/src/TimerCommand.h @@ -0,0 +1,90 @@ +#ifndef TimerCommand_h +#define TimerCommand_h + +#include +#include + +#include "TimerSettings.h" // the descriptor-table family classify validates against +#include "TimerHa.h" // TimerHaEntity (the attr-carrier dirty set) + +// The Timer command plan -- the PURE atomic-reject validation decision lifted out of +// the 244-line TimerManager::parseCommand, mirroring the +// SyncEnvelope sibling. classify() runs the entire validation pass once, +// mutating NOTHING, and returns a Plan that carries everything the impure shell needs +// to apply the command without re-reading the packet. +// +// classify reads NO globals: its only non-packet input is a Context filled by the +// shell from the globals it owns (the saved max_duration ceiling + the _remoteApply +// flag). That discipline -- mirroring SyncEnvelope's {ownId, follow} -- is what lets +// it link against the dependency-light descriptor-table family alone (no +// singleton), so any ceiling / remote scenario can be exercised as a plain +// Context value. +// +// The one-shot override STORE stays in TimerManager; classify computes only +// the oneShot DECISION. apply ordering matters: the shell must write the table rows +// (landing a raised max_duration) BEFORE calling setDuration(), which re-clamps against +// the GLOBAL ceiling -- classify validates a duration against the +// staged ceiling, so a too-early setDuration would silently re-clamp it. +namespace TimerCommand +{ + // The only non-packet input classify reads -- filled by the shell from the globals + // it owns. savedMaxDuration is the current ceiling (TIMER_MAX_DURATION); a command + // that also raises max_duration re-bases the ceiling for its own duration check. + // remoteApply forces the command one-shot (a follower mirrors but never + // persists). + struct Context + { + uint32_t savedMaxDuration; + bool remoteApply; + }; + + // Parsed run-state verb. None = the command carried no `action` key. + enum class Action : uint8_t { None, Start, Pause, Reset }; + + // Everything apply needs, so the shell never re-reads the packet. On !ok the only + // rejection classify produces is BadField (BadJson / Disabled are shell-level + // guards before classify); the shell maps !ok -> TimerCmdResult::BadField. All + // staging arrays are sized to the family's compile-time row caps. + struct Plan + { + bool ok = false; + + // Table half (TIMER_SETTINGS_DESCS): staged values + presence. A present row + // whose value was an inline melody is marked NOT present here (excluded from + // the bare-name store) and surfaced via the inline* fields instead. + TcValue tableStaged[TIMER_SETTINGS_DESC_CAP]; + bool tablePresent[TIMER_SETTINGS_DESC_CAP] = {false}; + + // Member half (TIMER_MEMBER_VALIDATORS, co-indexed with TIMER_MEMBER_CONFIG_DESCS). + TcValue memberStaged[TIMER_MEMBER_CONFIG_DESC_CAP]; + bool memberPresent[TIMER_MEMBER_CONFIG_DESC_CAP] = {false}; + + // duration (run-state, member-backed): pre-validated in range against the + // staged ceiling. The shell applies it via setDuration AFTER the table rows. + bool haveDuration = false; + uint32_t durationSec = 0; + + // Inline RTTTL melodies: validated tunes staged as RAM strings; the + // shell assigns them to endRtttl/tickRtttl after the bare-name resolve. Always + // one-shot (no persistable file form), so either forces oneShot. + bool haveInlineEnd = false; + bool haveInlineTick = false; + String inlineEnd; + String inlineTick; + + Action action = Action::None; // the parsed run-state verb (no re-parse in apply) + bool oneShot = false; // !save || inline melody || remoteApply + bool configInCommand = false; // config-block key present (table inSnapshot OR member half) + + // Carriers whose mapped HA-attribute keys appear in the command -- the set the + // shell republishes (suppressed under a one-shot command). Indexed by carrier. + bool attrCarrierDirty[(size_t)TimerHaEntity::COUNT] = {false}; + }; + + // Run the whole atomic-reject pass once, mutating nothing. On the first invalid + // field returns {ok=false} having staged nothing the caller should apply. On + // success returns {ok=true} with every field above populated. + Plan classify(JsonObjectConst packet, const Context &ctx); +} + +#endif diff --git a/src/TimerConfigEditor.cpp b/src/TimerConfigEditor.cpp new file mode 100644 index 00000000..4ec92417 --- /dev/null +++ b/src/TimerConfigEditor.cpp @@ -0,0 +1,98 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerConfigEditor.h" + +#include "Globals.h" // TIMER_MAX_DURATION (cap math) +#include "TimerSettings.h" // timerSecondsToHMS / timerHmsToSeconds (pure math free functions) + +namespace { + // Hold-to-repeat timing (was TimerManager.cpp's anon namespace). + constexpr unsigned long kBtnLongPressMs = 500; // hold this long before auto-repeat begins + constexpr unsigned long kBtnRepeatMs = 250; // cadence between auto-repeat steps +} + +void TimerConfigEditor::enter(uint32_t durationSec) +{ + uint32_t h, m, s; + timerSecondsToHMS(durationSec, h, m, s); + if (h > 99) h = 99; + hh_ = (uint8_t)h; + mm_ = (uint8_t)m; + ss_ = (uint8_t)s; + field_ = 0; + active_ = true; +} + +uint32_t TimerConfigEditor::exit() +{ + active_ = false; + return timerHmsToSeconds(hh_, mm_, ss_); +} + +void TimerConfigEditor::cycleField() +{ + if (!active_) return; + field_ = (field_ + 1) % 3; +} + +void TimerConfigEditor::adjust(int delta) +{ + if (!active_) return; + + const uint8_t hardMax = (field_ == 0) ? 99 : 59; + const uint32_t perUnit = (field_ == 0) ? 3600UL : (field_ == 1) ? 60UL : 1UL; + const uint32_t otherSec = (field_ == 0) + ? (uint32_t)mm_ * 60UL + (uint32_t)ss_ + : (field_ == 1) + ? (uint32_t)hh_ * 3600UL + (uint32_t)ss_ + : (uint32_t)hh_ * 3600UL + (uint32_t)mm_ * 60UL; + + uint8_t maxVal = hardMax; + if (TIMER_MAX_DURATION > 0) + { + uint32_t headroom = (TIMER_MAX_DURATION > otherSec) ? (TIMER_MAX_DURATION - otherSec) : 0; + uint32_t room = headroom / perUnit; + if (room < maxVal) maxVal = (uint8_t)room; + } + + uint8_t cur = (field_ == 0) ? hh_ : (field_ == 1 ? mm_ : ss_); + if (cur > maxVal) cur = maxVal; + int next = (int)cur + (delta >= 0 ? 1 : -1); + if (next < 0) next = maxVal; + else if (next > (int)maxVal) next = 0; + if (field_ == 0) hh_ = (uint8_t)next; + else if (field_ == 1) mm_ = (uint8_t)next; + else ss_ = (uint8_t)next; +} + +void TimerConfigEditor::tick(unsigned long nowMs, ButtonState buttons) +{ + if (!active_) return; + + // Hold-to-repeat: derive held time from nowMs vs a per-button press-start. + // While held past the long-press threshold, step once immediately, then once + // per repeat-cadence window. The editor never auto-applies on idle. + repeatHeld(buttons.leftPressed, nowMs, leftPressStartMs_, leftRepeatMs_, -1); + repeatHeld(buttons.rightPressed, nowMs, rightPressStartMs_, rightRepeatMs_, +1); +} + +void TimerConfigEditor::repeatHeld(bool pressed, unsigned long nowMs, + unsigned long &pressStartMs, unsigned long &repeatMs, + int delta) +{ + if (!pressed) + { + pressStartMs = 0; + repeatMs = 0; + return; + } + + if (pressStartMs == 0) pressStartMs = nowMs; // rising edge: start the hold clock + if (nowMs - pressStartMs < kBtnLongPressMs) return; + + if (repeatMs == 0 || (nowMs - repeatMs) >= kBtnRepeatMs) + { + adjust(delta); + repeatMs = nowMs; + } +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerConfigEditor.h b/src/TimerConfigEditor.h new file mode 100644 index 00000000..933de324 --- /dev/null +++ b/src/TimerConfigEditor.h @@ -0,0 +1,82 @@ +#ifndef TimerConfigEditor_h +#define TimerConfigEditor_h + +#include + +// The on-device duration editor (HH/MM/SS wheels), a display-free value object. It +// owns the editing working state — the field cursor and the three edit buffers — +// the cap-aware adjust math, and the button hold-to-repeat. It has no DisplayManager +// dependency and no hardware reference: tick(nowMs, buttonState) receives the +// current time and injected button presses, so the repeat cadence is testable +// without a real clock or buttons. Duration flows in via enter() and back out via +// exit(); the caller commits the result through setDuration(). +// +// There is no no-input auto-apply timeout (the TIMER menu — the editor's only +// host — is timeout-free); only hold-to-repeat remains. +class TimerConfigEditor +{ +public: + // Begin editing: decompose durationSec into HH/MM/SS, start on the HH field, + // become active. HH is clamped to 99 so the field stays two-digit-editable + // (the caller is responsible for any run-state clamp before handing it in). + void enter(uint32_t durationSec); + + // Finish editing: become inactive and return the edited duration in seconds. + // The caller commits it (TimerManager::setDuration). + uint32_t exit(); + + // Advance the highlighted field: HH -> MM -> SS -> HH. + void cycleField(); + + // Inc/dec the current field by one with wrapping. The wrap ceiling is the + // per-field hard max (99 for HH, 59 for MM/SS) further capped by the headroom + // left under the TIMER_MAX_DURATION global given the other two fields. + void adjust(int delta); + + // Injected per-tick button state: raw "pressed" reads (PeripheryManager + // buttonL/R isPressed()). The editor owns the long-press threshold and repeat + // cadence, deriving held time from the nowMs handed to tick(). + // An explicit two-arg constructor (alongside a defaulted default ctor) keeps the + // member defaults but makes construction standard-independent: a class with default + // member initializers is not an aggregate under C++11, so the call site's two-arg + // brace-init (TimerManager.cpp) would otherwise need the C++14 relaxed-aggregate + // rule and break the gnu++11 device build. + struct ButtonState + { + bool leftPressed = false; + bool rightPressed = false; + ButtonState() = default; + ButtonState(bool l, bool r) : leftPressed(l), rightPressed(r) {} + }; + + // Drive button hold-to-repeat: 500 ms long-press threshold then 250 ms cadence + // (left = -1 / right = +1). No timeout — the editor never auto-applies. + void tick(unsigned long nowMs, ButtonState buttons); + + bool isActive() const { return active_; } + uint8_t field() const { return field_; } + uint8_t hh() const { return hh_; } + uint8_t mm() const { return mm_; } + uint8_t ss() const { return ss_; } + +private: + // Drive one button's hold-to-repeat for the current field. pressStartMs/repeatMs + // are that button's bookkeeping (0 = unpressed / no step yet); delta is the + // field step (-1 left, +1 right). + void repeatHeld(bool pressed, unsigned long nowMs, + unsigned long &pressStartMs, unsigned long &repeatMs, int delta); + + bool active_ = false; + uint8_t field_ = 0; // 0 = HH, 1 = MM, 2 = SS + uint8_t hh_ = 0; + uint8_t mm_ = 0; + uint8_t ss_ = 0; + + // Per-button hold-to-repeat bookkeeping. 0 = unpressed / no repeat yet. + unsigned long leftPressStartMs_ = 0; + unsigned long rightPressStartMs_ = 0; + unsigned long leftRepeatMs_ = 0; + unsigned long rightRepeatMs_ = 0; +}; + +#endif diff --git a/src/TimerEnums.cpp b/src/TimerEnums.cpp new file mode 100644 index 00000000..185d9be5 --- /dev/null +++ b/src/TimerEnums.cpp @@ -0,0 +1,60 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerEnums.h" + +// One row per enum value, in enum-value order so the array index IS the enum +// value. Wire spellings are the canonical MQTT/HTTP/sync contract; menu/ha are the +// on-device and Home Assistant labels; aliases are extra accepted input spellings +// (parse only -- never emitted). +const TimerEnumCodec TIMER_BUZZER_CODEC[] = { + {"off", "OFF", "Off", nullptr}, + {"end", "END", "End", nullptr}, + {"countdown", "CDN", "Countdown", nullptr}, +}; +const size_t TIMER_BUZZER_CODEC_COUNT = + sizeof(TIMER_BUZZER_CODEC) / sizeof(TIMER_BUZZER_CODEC[0]); + +const TimerEnumCodec TIMER_FINISHED_CODEC[] = { + {"auto-clear", "CLEAR", "Auto-clear", "autoclear"}, + {"hold", "HOLD", "Hold", nullptr}, + {"re-alert", "RE-ALERT", "Re-alert", "realert"}, +}; +const size_t TIMER_FINISHED_CODEC_COUNT = + sizeof(TIMER_FINISHED_CODEC) / sizeof(TIMER_FINISHED_CODEC[0]); + +// Adding an enum value without giving it a table row (or vice versa) fails the +// build here -- the table count is pinned to the enum's COUNT sentinel. +static_assert(sizeof(TIMER_BUZZER_CODEC) / sizeof(TIMER_BUZZER_CODEC[0]) == + static_cast(BuzzerMode::COUNT), + "TIMER_BUZZER_CODEC must have one row per BuzzerMode value"); +static_assert(sizeof(TIMER_FINISHED_CODEC) / sizeof(TIMER_FINISHED_CODEC[0]) == + static_cast(FinishedMode::COUNT), + "TIMER_FINISHED_CODEC must have one row per FinishedMode value"); + +const TimerEnumCodec &buzzerCodec(BuzzerMode m) +{ + uint8_t i = static_cast(m); + if (i >= TIMER_BUZZER_CODEC_COUNT) i = 0; + return TIMER_BUZZER_CODEC[i]; +} + +const TimerEnumCodec &finishedCodec(FinishedMode m) +{ + uint8_t i = static_cast(m); + if (i >= TIMER_FINISHED_CODEC_COUNT) i = 0; + return TIMER_FINISHED_CODEC[i]; +} + +bool timerEnumParse(const TimerEnumCodec *table, size_t count, + const String &s, uint8_t &outIndex) +{ + String in = s; + in.toLowerCase(); + for (size_t i = 0; i < count; ++i) + { + if (in == table[i].wire) { outIndex = (uint8_t)i; return true; } + // aliases is a single extra spelling today; compare case-insensitively too. + if (table[i].aliases && in == table[i].aliases) { outIndex = (uint8_t)i; return true; } + } + return false; +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerEnums.h b/src/TimerEnums.h new file mode 100644 index 00000000..5cf9038d --- /dev/null +++ b/src/TimerEnums.h @@ -0,0 +1,57 @@ +#ifndef TimerEnums_h +#define TimerEnums_h + +#include + +// The Timer's two label-bearing enums and their per-enum CODEC TABLE: the fifth +// member of the descriptor-table family (alongside TIMER_SETTINGS_DESCS, +// TIMER_MEMBER_CONFIG_DESCS, TIMER_HA_DESCRIPTORS, TIMER_MENU_SLOTS). +// +// One row per enum value, INDEXED BY THE ENUM'S NUMERIC VALUE -- the enum value +// *is* the row index (the convention kBuzzerLabels/kFinishedLabels already used, +// now made authoritative). Each row carries every spelling of that value: +// * wire -- canonical MQTT/HTTP/sync string (the wire contract) +// * menu -- on-device TIMER-menu label +// * ha -- Home Assistant select-option label +// * aliases -- extra accepted INPUT spellings (parse only), nullptr if none +// +// This file is a findable leaf module: it depends only on Arduino.h, so it can be +// reused by TimerManager, TimerMenu and TimerHa without forming a dependency cycle +// (TimerManager.h includes THIS header for the enums, never the reverse). +// +// B1 boundary is unchanged: this only consolidates how labels / +// strings are ENCODED. The MQTT raw-uint8_t-index publish path is untouched -- the +// published index is the enum value, which is exactly this table's row index. + +// Timer lifecycle state. Lives here (not in TimerManager.h) so display-side value +// types like TimerSnapshot can carry it without depending on the manager singleton. +enum class TimerState : uint8_t { Idle = 0, Running = 1, Paused = 2, Finished = 3 }; + +enum class BuzzerMode : uint8_t { Off = 0, End = 1, Countdown = 2, COUNT }; +enum class FinishedMode : uint8_t { AutoClear = 0, Hold = 1, ReAlert = 2, COUNT }; + +struct TimerEnumCodec +{ + const char *wire; // canonical wire string (MQTT/HTTP/sync) + const char *menu; // on-device BARE leaf value (e.g. "END", not "BZR END") + const char *ha; // Home Assistant select-option label + const char *aliases; // extra accepted input spelling(s), nullptr if none +}; + +extern const TimerEnumCodec TIMER_BUZZER_CODEC[]; +extern const size_t TIMER_BUZZER_CODEC_COUNT; +extern const TimerEnumCodec TIMER_FINISHED_CODEC[]; +extern const size_t TIMER_FINISHED_CODEC_COUNT; + +// Row read by enum value (the value IS the row index). Bounds-clamped to row 0 so +// a stray cast can never index out of bounds. +const TimerEnumCodec &buzzerCodec(BuzzerMode m); +const TimerEnumCodec &finishedCodec(FinishedMode m); + +// Case-insensitive scan: match `s` against a row's canonical wire spelling OR any +// of its aliases. On a hit, sets `outIndex` to the matching row (= enum value) and +// returns true; otherwise leaves `outIndex` untouched and returns false. +bool timerEnumParse(const TimerEnumCodec *table, size_t count, + const String &s, uint8_t &outIndex); + +#endif diff --git a/src/TimerHa.cpp b/src/TimerHa.cpp new file mode 100644 index 00000000..de9819b3 --- /dev/null +++ b/src/TimerHa.cpp @@ -0,0 +1,163 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerHa.h" + +#include +#include + +#include "TimerEnums.h" + +// PROGMEM is a no-op for const data on the ESP32 (flash is memory-mapped and +// directly addressable), and isn't defined on the native host build. Define it +// away when absent so this table compiles in both environments. +#ifndef PROGMEM +#define PROGMEM +#endif + +// Build a select's "`;`"-joined HA option list from a per-enum codec table's HA +// column, in enum-value order (the table's row index IS the enum value, pinned by +// its static_assert —). One join site, no hand-written list to +// drift from the enum labels. The returned String has static lifetime below, so +// the descriptor's `options` pointer stays valid for the program's life. +static String joinHaOptions(const TimerEnumCodec *table, size_t count) +{ + String s; + for (size_t i = 0; i < count; ++i) + { + if (i) s += ';'; + s += table[i].ha; + } + return s; +} + +// HA entity strings for the Timer. Co-located with the descriptor table so the +// Timer's HA contract lives in one place (moved here from Dictionary.cpp). +static const char HAtimerDurID[] PROGMEM = {"%s_timer_dur"}; +static const char HAtimerDurIcon[] PROGMEM = {"mdi:timer-cog-outline"}; +static const char HAtimerDurName[] PROGMEM = {"Timer duration"}; + +static const char HAtimerRemID[] PROGMEM = {"%s_timer_rem"}; +static const char HAtimerRemIcon[] PROGMEM = {"mdi:timer-sand"}; +static const char HAtimerRemName[] PROGMEM = {"Timer remaining"}; +static const char HAtimerRemUnit[] PROGMEM = {"s"}; +static const char HAtimerRemClass[] PROGMEM = {"duration"}; + +static const char HAtimerStateID[] PROGMEM = {"%s_timer_state"}; +static const char HAtimerStateIcon[] PROGMEM = {"mdi:state-machine"}; +static const char HAtimerStateName[] PROGMEM = {"Timer state"}; + +static const char HAtimerBuzID[] PROGMEM = {"%s_timer_buz"}; +static const char HAtimerBuzIcon[] PROGMEM = {"mdi:volume-high"}; +static const char HAtimerBuzName[] PROGMEM = {"Timer buzzer"}; +static const String HAtimerBuzOptions = joinHaOptions(TIMER_BUZZER_CODEC, TIMER_BUZZER_CODEC_COUNT); + +static const char HAtimerFinID[] PROGMEM = {"%s_timer_fin"}; +static const char HAtimerFinIcon[] PROGMEM = {"mdi:bell-ring-outline"}; +static const char HAtimerFinName[] PROGMEM = {"Timer finished mode"}; +static const String HAtimerFinOptions = joinHaOptions(TIMER_FINISHED_CODEC, TIMER_FINISHED_CODEC_COUNT); + +static const char HAtimerStartID[] PROGMEM = {"%s_timer_start"}; +static const char HAtimerStartIcon[] PROGMEM = {"mdi:play"}; +static const char HAtimerStartName[] PROGMEM = {"Timer start"}; + +static const char HAtimerPauseID[] PROGMEM = {"%s_timer_pause"}; +static const char HAtimerPauseIcon[] PROGMEM = {"mdi:pause"}; +static const char HAtimerPauseName[] PROGMEM = {"Timer pause"}; + +static const char HAtimerResetID[] PROGMEM = {"%s_timer_reset"}; +static const char HAtimerResetIcon[] PROGMEM = {"mdi:restore"}; +static const char HAtimerResetName[] PROGMEM = {"Timer reset"}; + +// Sync-control entities: the two sync settings — until now read-only +// HA attributes on the state sensor — become writable. The Follow switch carries +// the receive-consent toggle; the Targets select ships STATIC Off/All only (it +// becomes dynamic in a later peer-discovery slice). +static const char HAtimerSyncFollowID[] PROGMEM = {"%s_timer_sync_follow"}; +static const char HAtimerSyncFollowIcon[] PROGMEM = {"mdi:account-sync"}; +static const char HAtimerSyncFollowName[] PROGMEM = {"Timer sync follow"}; + +static const char HAtimerSyncTargetsID[] PROGMEM = {"%s_timer_sync_targets"}; +static const char HAtimerSyncTargetsIcon[] PROGMEM = {"mdi:target-account"}; +static const char HAtimerSyncTargetsName[] PROGMEM = {"Timer sync targets"}; +// Static option list, in TimerSyncTargetsOption order (Off=0, All=1). The select's +// state index reflects sync_targets via timerSyncTargetsIndexForValue. +static const char HAtimerSyncTargetsOptions[] PROGMEM = {"Off;All"}; + +const TimerHaDescriptor TIMER_HA_DESCRIPTORS[TIMER_HA_DESCRIPTOR_COUNT] = { + {TimerHaEntity::Duration, "text", HAtimerDurID, HAtimerDurIcon, HAtimerDurName, nullptr, nullptr, nullptr}, + {TimerHaEntity::Remaining,"sensor", HAtimerRemID, HAtimerRemIcon, HAtimerRemName, nullptr, HAtimerRemUnit, HAtimerRemClass}, + {TimerHaEntity::State, "sensor", HAtimerStateID, HAtimerStateIcon, HAtimerStateName, nullptr, nullptr, nullptr}, + {TimerHaEntity::Buzzer, "select", HAtimerBuzID, HAtimerBuzIcon, HAtimerBuzName, HAtimerBuzOptions.c_str(), nullptr, nullptr}, + {TimerHaEntity::Finished, "select", HAtimerFinID, HAtimerFinIcon, HAtimerFinName, HAtimerFinOptions.c_str(), nullptr, nullptr}, + {TimerHaEntity::Start, "button", HAtimerStartID, HAtimerStartIcon, HAtimerStartName, nullptr, nullptr, nullptr}, + {TimerHaEntity::Pause, "button", HAtimerPauseID, HAtimerPauseIcon, HAtimerPauseName, nullptr, nullptr, nullptr}, + {TimerHaEntity::Reset, "button", HAtimerResetID, HAtimerResetIcon, HAtimerResetName, nullptr, nullptr, nullptr}, + {TimerHaEntity::SyncFollow, "switch", HAtimerSyncFollowID, HAtimerSyncFollowIcon, HAtimerSyncFollowName, nullptr, nullptr, nullptr}, + {TimerHaEntity::SyncTargets, "select", HAtimerSyncTargetsID, HAtimerSyncTargetsIcon, HAtimerSyncTargetsName, HAtimerSyncTargetsOptions, nullptr, nullptr}, +}; + +// --- Dynamic SyncTargets select --------------------------------------- +// The select's options and id<->index mapping are derived at runtime from the +// CURRENT peer-id list. See the contract in TimerHa.h. + +void timerSyncTargetsBuildOptions(const char *const *ids, size_t n, char *out, size_t outLen) +{ + // Base options are always present; peers append in the caller-provided (sorted) + // order. String here is local scratch — the device build has it via Arduino.h. + String s = "Off;All"; + for (size_t i = 0; i < n; ++i) + { + s += ';'; + s += ids[i]; + } + snprintf(out, outLen, "%s", s.c_str()); +} + +int8_t timerSyncTargetsIndexForValue(const char *syncTargets, const char *const *ids, size_t n) +{ + if (syncTargets == nullptr || syncTargets[0] == '\0') return (int8_t)TimerSyncTargetsOption::Off; + if (strcmp(syncTargets, "all") == 0) return (int8_t)TimerSyncTargetsOption::All; + // A multi-id CSV cannot be expressed as a single select option -> unknown. + if (strchr(syncTargets, ',') != nullptr) return -1; + // A single specific id reflects only while it is still a discovered peer; the + // index follows its position in the sorted list (after the two base options). + for (size_t i = 0; i < n; ++i) + if (strcmp(syncTargets, ids[i]) == 0) return (int8_t)(2 + i); + return -1; // an id no longer present -> unknown (HASelect: no option selected) +} + +bool timerSyncTargetsValueForIndex(int8_t index, const char *const *ids, size_t n, char *out, size_t outLen) +{ + if (index == (int8_t)TimerSyncTargetsOption::Off) { snprintf(out, outLen, "%s", ""); return true; } + if (index == (int8_t)TimerSyncTargetsOption::All) { snprintf(out, outLen, "%s", "all"); return true; } + if (index >= 2 && (size_t)(index - 2) < n) { snprintf(out, outLen, "%s", ids[index - 2]); return true; } + return false; +} + +void formatTimerHaEntityId(const TimerHaDescriptor &d, const char *macSuffix, char *out, size_t outLen) +{ + snprintf(out, outLen, d.idFormat, macSuffix); +} + +void formatTimerHaDataTopic(const char *dataPrefix, const char *deviceUniqueId, + const char *entityId, char *out, size_t outLen) +{ + // "stat_t" is ArduinoHA's HAStateTopic suffix; see the contract in TimerHa.h. + snprintf(out, outLen, "%s/%s/%s/stat_t", dataPrefix, deviceUniqueId, entityId); +} + +void formatTimerHaAttrTopic(const char *dataPrefix, const char *deviceUniqueId, + const char *entityId, char *out, size_t outLen) +{ + // "json_attr_t" is ArduinoHA's HAJsonAttributesTopic suffix; see the contract + // in TimerHa.h. + snprintf(out, outLen, "%s/%s/%s/json_attr_t", dataPrefix, deviceUniqueId, entityId); +} + +bool haRegistrationAtCap(uint8_t registered, uint8_t maxEntities) +{ + // Mirror byte-for-byte ArduinoHA's HAMqtt::addDeviceType guard + // (`_devicesTypesNb + 1 >= _maxDevicesTypesNb`); the uint16_t widening keeps a + // maxEntities of 255 from wrapping. See the contract in TimerHa.h. + return (uint16_t)registered + 1 >= maxEntities; +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerHa.h b/src/TimerHa.h new file mode 100644 index 00000000..7d83853e --- /dev/null +++ b/src/TimerHa.h @@ -0,0 +1,116 @@ +#ifndef TimerHa_h +#define TimerHa_h + +#include +#include + +// Timer HA Presence: the single source of truth for the Home Assistant entities +// that project the Timer over the MQTT discovery layer. This header is pure data +// (no ArduinoHA, no Globals) so it compiles on the host and the contract below can +// be unit-tested. The ArduinoHA `new HAX + setters` apply and the discovery +// teardown both read TIMER_HA_DESCRIPTORS in MQTTManager — one table, no drift. +// See docs/timer.md. + +enum class TimerHaEntity : uint8_t +{ + Duration = 0, + Remaining, + State, + Buzzer, + Finished, + Start, + Pause, + Reset, + SyncFollow, // writable switch: the receive-consent toggle (sync_follow) + SyncTargets, // writable select: static Off/All targeting (sync_targets) + COUNT +}; + +// The static option indices for the SyncTargets select. The select +// ships ONLY these two base options ("Off"/"All"); a specific-ID CSV set out-of-band +// is reflected as unknown (-1) — see timerSyncTargetsIndexForValue. +enum class TimerSyncTargetsOption : int8_t +{ + Off = 0, // sync_targets == "" (receive only / no relay) + All = 1, // sync_targets == "all" + COUNT +}; + +struct TimerHaDescriptor +{ + TimerHaEntity slot; + const char *component; // HA discovery component: "text" | "sensor" | "select" | "button" + const char *idFormat; // unique-id format, e.g. "%s_timer_dur" ("%s" is the MAC suffix) + const char *icon; + const char *name; + const char *options; // select only ("`;`"-joined), else nullptr + const char *unit; // sensor only, else nullptr + const char *deviceClass; // sensor only, else nullptr +}; + +// One descriptor per TimerHaEntity slot, in slot order (TIMER_HA_DESCRIPTORS[(size_t)slot]). +constexpr size_t TIMER_HA_DESCRIPTOR_COUNT = static_cast(TimerHaEntity::COUNT); +extern const TimerHaDescriptor TIMER_HA_DESCRIPTORS[TIMER_HA_DESCRIPTOR_COUNT]; + +// Convenience accessor: the descriptor for a given slot. +inline const TimerHaDescriptor &timerHaDescriptor(TimerHaEntity slot) +{ + return TIMER_HA_DESCRIPTORS[static_cast(slot)]; +} + +// The single place a descriptor row + device MAC suffix becomes an entity's HA +// discovery unique id: formats the row's idFormat ("%s" -> macSuffix) into +// out[outLen]. Discovery setup (fill) and teardown both derive ids through this, +// keyed by the row they hold, so the two cannot derive different ids for a slot +// and reordering the table moves no entity's id. +void formatTimerHaEntityId(const TimerHaDescriptor &d, const char *macSuffix, char *out, size_t outLen); + +// The single place an entity id becomes the full MQTT data (state) topic the +// broker sees: "{dataPrefix}/{deviceUniqueId}/{entityId}/stat_t". MUST stay +// byte-identical to what ArduinoHA emits for the same entity +// (HASerializer::generateDataTopic with the HAStateTopic suffix "stat_t") — +// the wire seam publishes to this topic where setValue() used to, so a format +// drift here is a wire-protocol change. Pinned by test W1. +void formatTimerHaDataTopic(const char *dataPrefix, const char *deviceUniqueId, + const char *entityId, char *out, size_t outLen); + +// The finished-mode select's JSON-attributes data topic: +// "{dataPrefix}/{deviceUniqueId}/{entityId}/json_attr_t". Same data-topic shape +// as formatTimerHaDataTopic but with ArduinoHA's HAJsonAttributesTopic suffix +// ("json_attr_t") — MUST stay byte-identical to what HASelect::setJsonAttributes +// advertises in the discovery config, since the retained attribute value rides +// the wire seam to this exact topic. Pinned by test W13. +void formatTimerHaAttrTopic(const char *dataPrefix, const char *deviceUniqueId, + const char *entityId, char *out, size_t outLen); + +// Dynamic SyncTargets select. The select consumes the peer registry, so its +// option list and id<->index mapping are built at runtime from the CURRENT set of +// discovered peer ids (already sorted) rather than the static "Off;All" table field. +// All three helpers are pure (char* / out-buffer, no String/Globals), like the +// rest of this contract. + +// Build the option list "Off;All" followed by ";" for each of the n peer ids, +// in the given order, into out[outLen] (truncated like snprintf). n==0 -> "Off;All". +void timerSyncTargetsBuildOptions(const char *const *ids, size_t n, char *out, size_t outLen); + +// Forward map (sync_targets value -> select option index) over the CURRENT id list: +// "" -> 0 (Off), "all" -> 1 (All), a single id present at sorted position k -> 2+k, +// an id no longer present OR a multi-id CSV -> -1 (unknown / no option selected). +// With n==0 this reduces to static Off/All/-1 behaviour (the select's base options). +int8_t timerSyncTargetsIndexForValue(const char *syncTargets, const char *const *ids, size_t n); + +// Reverse map (select option index -> sync_targets value) over the CURRENT id list: +// 0 -> "", 1 -> "all", k>=2 -> ids[k-2], written into out[outLen]. Returns false +// (and leaves out untouched) when index is out of range — the command path's BadField. +bool timerSyncTargetsValueForIndex(int8_t index, const char *const *ids, size_t n, char *out, size_t outLen); + +// ArduinoHA's HAMqtt::addDeviceType drops an entity when +// `_devicesTypesNb + 1 >= _maxDevicesTypesNb`, so the EFFECTIVE capacity is +// maxEntities - 1, not maxEntities (an off-by-one that silently dropped the two +// Timer sync-control entities). This helper encodes that guard once, +// so the DEBUG_MODE registration check and ArduinoHA agree: it returns +// true when `registered` entities already fill the effective cap, i.e. the next +// addDeviceType would be rejected. Pure (no ArduinoHA/Globals). +bool haRegistrationAtCap(uint8_t registered, uint8_t maxEntities); + +#endif diff --git a/src/TimerHaHost.cpp b/src/TimerHaHost.cpp new file mode 100644 index 00000000..249e94f9 --- /dev/null +++ b/src/TimerHaHost.cpp @@ -0,0 +1,438 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerHaHost.h" +#include +#include +#include "Globals.h" +#include "MQTTManager.h" // kMaxHAEntities (the general MQTT module's HA entity cap) +#include "TimerManager.h" +#include "TimerSettings.h" // timerFormatHMS (the Duration state's clock string) +#include "TimerHa.h" +#include "SyncTargetsDebounce.h" + +// The ArduinoHA client + device stay owned by the general MQTT translation unit +// (MQTTManager.cpp); this file reaches them via extern (no injection). +extern HADevice device; +extern HAMqtt mqtt; + +// The shared ArduinoHA callbacks stay in MQTTManager (they keep their non-Timer +// branches); createCarriers() wires the Timer carriers onto them, and each delegates +// its Timer branch back to this host via tryHandle* (leading guard in MQTTManager). +extern void onButtonCommand(HAButton *sender); +extern void onSelectCommand(int8_t index, HASelect *sender); +extern void onSwitchCommand(bool state, HASwitch *sender); + +// --- Carrier state (private to the host) ------------------------------------- +// The ten HA carrier entity pointers, all file-local. +static HAText *timerDuration = nullptr; +static HASensorNumber *timerRemaining = nullptr; +static HASensor *timerStateSensor = nullptr; +static HASelect *timerBuzzer = nullptr, *timerFinishedSel = nullptr; +static HAButton *timerStartBtn = nullptr, *timerPauseBtn = nullptr, *timerResetBtn = nullptr; +// Sync-control entities: the writable Follow switch and dynamic +// Off/All Targets select that make the two sync settings controllable from HA. +static HASwitch *timerSyncFollowSw = nullptr; +static HASelect *timerSyncTargetsSel = nullptr; + +// Each Timer entity's resolved HA discovery unique id ("%s" filled with the MAC), +// indexed by TimerHaEntity slot (timerHaIds[(size_t)slot]). Filled once in setup() +// via formatTimerHaEntityId() and read by both createCarriers() and remove() +// through timerHaId(slot) — never by hand-ordered position, so create and teardown +// cannot drift. The strings must outlive the entities: ArduinoHA stores the +// unique-id pointer, not a copy. Exposed to MQTTManager's wire seam via entityId(). +static char timerHaIds[TIMER_HA_DESCRIPTOR_COUNT][40]; + +// The id buffer for a given Timer HA slot. See timerHaIds above. +static char *timerHaId(TimerHaEntity slot) { return timerHaIds[static_cast(slot)]; } + +// --- Dynamic Targets select debounce state (private to the host) ------ +// Option-string sizing for the dynamic Targets select. +constexpr size_t kSyncTargetPeerCap = 16; +constexpr size_t kSyncTargetOptsCap = 8 + kSyncTargetPeerCap * 33 + 1; // "Off;All" + ";"... +// The settle-window republish decision (signature, dirty flag, window start) lives in +// the SyncTargetsDebounce module; this instance +// is the only debounce state. createCarriers() seeds it; refreshTargets() steps it. +static SyncTargetsDebounce syncTargetsDebounce; + +// Snapshot the current sorted peer ids and build the select's option string into +// optsOut. Returns the peer count; idsOut holds the ids (whose c_str() backs ptrsOut) +// so the caller can also map a value<->index against the SAME list. +static size_t buildCurrentSyncTargetsOptions(String *idsOut, const char **ptrsOut, size_t cap, + char *optsOut, size_t optsLen) +{ + size_t n = TimerManager.peerIds(idsOut, cap); + for (size_t i = 0; i < n; ++i) ptrsOut[i] = idsOut[i].c_str(); + timerSyncTargetsBuildOptions(ptrsOut, n, optsOut, optsLen); + return n; +} + +// --- SHOW_TIMER reconcile bookkeeping (private to the host) ------------------ +// Set by reconcile() when SHOW_TIMER latched off across a reboot; onConnected() +// consumes it once MQTT is up to prune the Timer carriers' discovery. Private host +// state — nothing outside the host touches it. SHOW_TIMER_HA_PREV, by +// contrast, is a persisted (NVS-backed) shared global the settings-apply path writes +// independently, so it stays a Globals-owned flag reconcile() reads/writes via extern +// — exactly like SHOW_TIMER itself. +static bool pendingTimerHADiscoveryCleanup = false; + +// The dedicated Timer duration text callback (registered on the Duration carrier). +static void onTimerDurationMessage(const char *message, uint16_t length, HAText *sender) +{ + String in; + in.reserve(length); + for (uint16_t i = 0; i < length; i++) in += message[i]; + in.trim(); + + // Route the raw HH:MM:SS text through parseCommand: it owns the + // parse/validate (timerParseHMS + range, reject-not-clamp) — the HA layer no longer + // duplicates that logic. A rejected input applies nothing (atomic-reject). + TimerManager.timerHaApply(TimerHaEntity::Duration, in); + + // Echo the canonical value back so rejected input snaps the field to the + // previous valid time rather than leaving the bad text displayed. + sender->setState(timerFormatHMS(TimerManager.getDuration()).c_str(), true); +} + +// Creates the Timer HA entity objects (and registers them with HAMqtt via their +// constructors). Idempotent: the objects persist for the device lifetime, so a second +// call is a no-op. This must run with the Timer entity id buffers already resolved +// (setup() fills them before SHOW_TIMER is consulted). +static void createCarriers() +{ + if (timerDuration) return; + + // Each entity's strings come from the descriptor table; the ArduinoHA + // object type and the type-specific wiring (callbacks, initial state) + // stay here because they're heterogeneous across HA component types. + const TimerHaDescriptor &dDur = timerHaDescriptor(TimerHaEntity::Duration); + const TimerHaDescriptor &dRem = timerHaDescriptor(TimerHaEntity::Remaining); + const TimerHaDescriptor &dState = timerHaDescriptor(TimerHaEntity::State); + const TimerHaDescriptor &dBuz = timerHaDescriptor(TimerHaEntity::Buzzer); + const TimerHaDescriptor &dFin = timerHaDescriptor(TimerHaEntity::Finished); + const TimerHaDescriptor &dStart = timerHaDescriptor(TimerHaEntity::Start); + const TimerHaDescriptor &dPause = timerHaDescriptor(TimerHaEntity::Pause); + const TimerHaDescriptor &dReset = timerHaDescriptor(TimerHaEntity::Reset); + + timerDuration = new HAText(timerHaId(TimerHaEntity::Duration)); + timerDuration->setIcon(dDur.icon); + timerDuration->setName(dDur.name); + timerDuration->setRetain(true); + timerDuration->onMessage(onTimerDurationMessage); + timerDuration->setState(timerFormatHMS(TimerManager.getDuration()).c_str(), true); + // Opt the Duration text entity into JSON attributes (HAText opt-in) + // so its discovery config advertises json_attr_t; its retained {max_duration} + // object — the cap in carrier-native clock form ("24:00:00") — rides the wire + // seam to that topic. No TIMER_HA_DESCRIPTORS change. + timerDuration->setJsonAttributes(true); + + timerRemaining = new HASensorNumber(timerHaId(TimerHaEntity::Remaining), HASensorNumber::PrecisionP0); + timerRemaining->setIcon(dRem.icon); + timerRemaining->setName(dRem.name); + timerRemaining->setUnitOfMeasurement(dRem.unit); + timerRemaining->setDeviceClass(dRem.deviceClass); + timerRemaining->setCurrentValue((uint32_t)TimerManager.getRemaining()); + // Opt the remaining sensor into JSON attributes (HASensorNumber inherits the + // opt-in from HASensor): its retained {remaining_publish_interval} object rides + // the wire seam to json_attr_t. No descriptor change. + timerRemaining->setJsonAttributes(true); + + timerStateSensor = new HASensor(timerHaId(TimerHaEntity::State)); + timerStateSensor->setIcon(dState.icon); + timerStateSensor->setName(dState.name); + // Opt the state sensor into JSON attributes so its discovery config advertises + // json_attr_t; the retained config-view object (max_duration, bar_color as + // "#RRGGBB", sync roles, …) rides the wire seam to that topic. + timerStateSensor->setJsonAttributes(true); + + timerBuzzer = new HASelect(timerHaId(TimerHaEntity::Buzzer)); + timerBuzzer->setOptions(dBuz.options); + timerBuzzer->onCommand(onSelectCommand); + timerBuzzer->setIcon(dBuz.icon); + timerBuzzer->setName(dBuz.name); + timerBuzzer->setState((uint8_t)TimerManager.getBuzzerMode(), true); + // Opt the buzzer select into JSON attributes so its discovery config advertises + // json_attr_t; its retained {countdown_seconds, melody_tick, melody_end} object + // rides the wire seam to that topic. No descriptor change. + timerBuzzer->setJsonAttributes(true); + + timerFinishedSel = new HASelect(timerHaId(TimerHaEntity::Finished)); + timerFinishedSel->setOptions(dFin.options); + timerFinishedSel->onCommand(onSelectCommand); + timerFinishedSel->setIcon(dFin.icon); + timerFinishedSel->setName(dFin.name); + timerFinishedSel->setState((uint8_t)TimerManager.getFinishedMode(), true); + // Opt the finished select into JSON attributes so its discovery config + // advertises json_attr_t; the retained {"realert_interval":N} then rides the + // wire seam to that topic. No TIMER_HA_DESCRIPTORS change. + timerFinishedSel->setJsonAttributes(true); + + timerStartBtn = new HAButton(timerHaId(TimerHaEntity::Start)); + timerStartBtn->setIcon(dStart.icon); + timerStartBtn->setName(dStart.name); + timerStartBtn->onCommand(onButtonCommand); + + timerPauseBtn = new HAButton(timerHaId(TimerHaEntity::Pause)); + timerPauseBtn->setIcon(dPause.icon); + timerPauseBtn->setName(dPause.name); + timerPauseBtn->onCommand(onButtonCommand); + + timerResetBtn = new HAButton(timerHaId(TimerHaEntity::Reset)); + timerResetBtn->setIcon(dReset.icon); + timerResetBtn->setName(dReset.name); + timerResetBtn->onCommand(onButtonCommand); + + // Sync-control entities. The two sync settings — until now + // read-only attributes on the state sensor — become writable here. Both + // callbacks route through TimerManager::timerHaApply -> parseCommand, so they + // inherit the atomic-reject validation and NVS persistence the rest of the + // control surface has. sync_* are local identity (inSnapshot=false) and never + // propagate to peers —. + const TimerHaDescriptor &dSyncF = timerHaDescriptor(TimerHaEntity::SyncFollow); + const TimerHaDescriptor &dSyncT = timerHaDescriptor(TimerHaEntity::SyncTargets); + + timerSyncFollowSw = new HASwitch(timerHaId(TimerHaEntity::SyncFollow)); + timerSyncFollowSw->setIcon(dSyncF.icon); + timerSyncFollowSw->setName(dSyncF.name); + timerSyncFollowSw->onCommand(onSwitchCommand); + timerSyncFollowSw->setState(TIMER_SYNC_FOLLOW, true); + + timerSyncTargetsSel = new HASelect(timerHaId(TimerHaEntity::SyncTargets)); + // Dynamic options: "Off;All" plus each currently-discovered peer id, + // not the static dSyncT.options table field — the select tracks the peer registry. + String stIds[kSyncTargetPeerCap]; + const char *stPtrs[kSyncTargetPeerCap]; + char stOpts[kSyncTargetOptsCap]; + size_t stN = buildCurrentSyncTargetsOptions(stIds, stPtrs, kSyncTargetPeerCap, + stOpts, sizeof(stOpts)); + timerSyncTargetsSel->setOptions(stOpts); + syncTargetsDebounce.seed(stOpts); // seed the republish baseline (no spurious first republish) + timerSyncTargetsSel->onCommand(onSelectCommand); + timerSyncTargetsSel->setIcon(dSyncT.icon); + timerSyncTargetsSel->setName(dSyncT.name); + // Reflect the current sync_targets against the CURRENT id list: Off/All, the + // matching peer's option, or unknown (-1) for a multi-ID CSV / an id no longer + // present — the read-only attribute stays authoritative for the exact value. + timerSyncTargetsSel->setState( + timerSyncTargetsIndexForValue(TIMER_SYNC_TARGETS.c_str(), stPtrs, stN), true); + + // Every `new HAX` above auto-registered with HAMqtt via the + // HABaseDeviceType ctor. ArduinoHA drops entities once registration reaches the + // effective cap (kMaxHAEntities - 1; see haRegistrationAtCap) — which silently + // dropped these two sync-control entities until the cap was raised. Log the count + // and warn if we're back at the cap (the runtime guard, since the build-flag- + // conditional base count rules out a clean compile-time check). + if (DEBUG_MODE) + { + uint8_t registered = mqtt.getDevicesTypesNb(); + DEBUG_PRINTF("HA entities registered: %u of %u", registered, kMaxHAEntities); + if (haRegistrationAtCap(registered, kMaxHAEntities)) + DEBUG_PRINTLN(F("WARN: HA entity registration at effective cap; entities may be dropped (raise kMaxHAEntities)")); + } +} + +void TimerHaHost_::setup() +{ + // Resolve every Timer entity's unique id from the Timer HA Presence table into + // the slot-keyed timerHaIds buffers (also read by teardown), each through the one + // formatTimerHaEntityId() helper so create and teardown agree per slot. Row i + // describes slot i. Same MAC suffix MQTTManager::setup() + // feeds its own entities, so the ids are unchanged by the move. + uint8_t mac[6]; + WiFi.macAddress(mac); + char macStr[7]; + snprintf(macStr, 7, "%02x%02x%02x", mac[3], mac[4], mac[5]); + for (size_t i = 0; i < TIMER_HA_DESCRIPTOR_COUNT; ++i) + formatTimerHaEntityId(TIMER_HA_DESCRIPTORS[i], macStr, timerHaIds[i], sizeof(timerHaIds[i])); + + if (SHOW_TIMER) + createCarriers(); +} + +void TimerHaHost_::onConnected() +{ + // Flush the one-shot cleanup reconcile() latched: if SHOW_TIMER went off across a + // reboot, prune the Timer carriers' retained discovery now that MQTT is up. When + // set, SHOW_TIMER is false, so the publish block below is skipped this connect. + if (pendingTimerHADiscoveryCleanup) + { + remove(); + pendingTimerHADiscoveryCleanup = false; + } + if (SHOW_TIMER) + { + TimerManager.publishAllWire(); // every wire artifact, derived from the member table + TimerManager.publishAllAttributeGroups(); // every carrier's read-only attribute object + } +} + +// Reconcile SHOW_TIMER against its persisted last-seen value at boot (before MQTT +// connects). This is the host equivalent of the free reconcileTimerHAState() that +// lived in MQTTManager: if the timer was toggled off while powered down, +// latch the one-shot discovery cleanup onConnected() flushes; and persist the new +// last-seen value whenever it changed. SHOW_TIMER_HA_PREV stays a shared persisted +// global (settings-apply writes it too), reached via extern —. +void TimerHaHost_::reconcile() +{ + if (SHOW_TIMER_HA_PREV && !SHOW_TIMER) + { + pendingTimerHADiscoveryCleanup = true; + } + if (SHOW_TIMER_HA_PREV != SHOW_TIMER) + { + SHOW_TIMER_HA_PREV = SHOW_TIMER; + saveSettings(); + } +} + +// Brings the Timer HA entities online at runtime when SHOW_TIMER flips false->true. +// Mirror of remove(): creates the entities if missing, then publishes their discovery +// config (so HA adds them without a full reconnect) and current values. +void TimerHaHost_::enable() +{ + if (!HA_DISCOVERY) return; + createCarriers(); + if (!mqtt.isConnected()) return; // discovery will publish at next connect via onConnected() + + HABaseDeviceType *timerTypes[] = { + timerDuration, timerRemaining, timerStateSensor, timerBuzzer, + timerFinishedSel, timerStartBtn, timerPauseBtn, timerResetBtn, + timerSyncFollowSw, timerSyncTargetsSel}; + for (HABaseDeviceType *dt : timerTypes) + mqtt.publishConfigForDeviceType(dt); + + TimerManager.publishAllWire(); // every wire artifact, derived from the member table + TimerManager.publishAllAttributeGroups(); // every carrier's read-only attribute object +} + +void TimerHaHost_::remove() +{ + const char *deviceUniqueId = device.getUniqueId(); + if (!deviceUniqueId) return; + char topic[160]; + for (size_t i = 0; i < TIMER_HA_DESCRIPTOR_COUNT; ++i) + { + const TimerHaDescriptor &d = TIMER_HA_DESCRIPTORS[i]; + snprintf(topic, sizeof(topic), "%s/%s/%s/%s/config", + HA_PREFIX.c_str(), d.component, deviceUniqueId, timerHaId(d.slot)); + mqtt.publish(topic, "", true); + } + // Clear each carrier's retained json_attr_t object too, so pruning the + // discovery config does not leave an orphaned attribute payload behind on the + // broker. Rides the same wire seam the attribute publish does. + TimerManager.clearAllAttributeGroups(); +} + +// Re-publish the Targets select's discovery when peer-registry membership changes, +// debounced so a burst of beacon churn yields one republish. No-op when +// the timer HA entities are absent or MQTT is down (discovery re-publishes at the next +// connect). On a settled change it rebuilds the options, re-publishes the discovery +// config so Home Assistant sees the new list, and re-applies the selected state. +void TimerHaHost_::refreshTargets(unsigned long nowMs) +{ + if (timerSyncTargetsSel == nullptr) return; + if (!mqtt.isConnected()) return; + + String ids[kSyncTargetPeerCap]; + const char *ptrs[kSyncTargetPeerCap]; + char opts[kSyncTargetOptsCap]; + size_t n = buildCurrentSyncTargetsOptions(ids, ptrs, kSyncTargetPeerCap, + opts, sizeof(opts)); + + // The settle-window decision (unchanged / start window / waiting / republish) is + // SyncTargetsDebounce's transition table; only Republish reaches the effects below. + // The guards above stay BEFORE step() so window state ages across MQTT-down gaps. + if (syncTargetsDebounce.step(opts, nowMs) != SyncTargetsDebounce::Action::Republish) + return; + + timerSyncTargetsSel->resetOptions(); + timerSyncTargetsSel->setOptions(opts); + mqtt.publishConfigForDeviceType(timerSyncTargetsSel); + timerSyncTargetsSel->setState( + timerSyncTargetsIndexForValue(TIMER_SYNC_TARGETS.c_str(), ptrs, n), true); +} + +bool TimerHaHost_::tryHandleButton(HAButton *sender) +{ + if (sender == timerStartBtn) + { + // Route through parseCommand: start/pause/reset re-enter the + // control surface, so HA buttons drive peer clocks (run-state propagation) + // the same way the MQTT topic does. parseCommand also owns the + // start-from-idle switch-to-Timer-app behaviour, so it isn't duplicated here. + TimerManager.timerHaApply(TimerHaEntity::Start, ""); + return true; + } + if (sender == timerPauseBtn) + { + TimerManager.timerHaApply(TimerHaEntity::Pause, ""); + return true; + } + if (sender == timerResetBtn) + { + TimerManager.timerHaApply(TimerHaEntity::Reset, ""); + return true; + } + return false; +} + +bool TimerHaHost_::tryHandleSwitch(bool state, HASwitch *sender) +{ + if (sender == timerSyncFollowSw) + { + // Route through parseCommand: the Follow switch re-enters the + // control surface, so it gets the strict-bool atomic-reject validation and + // NVS persistence. sync_follow is local identity and never propagates. + TimerManager.timerHaApply(TimerHaEntity::SyncFollow, String(state ? 1 : 0)); + // Echo the value actually applied back (snap-back if a write were rejected). + sender->setState(TIMER_SYNC_FOLLOW); + return true; + } + return false; +} + +bool TimerHaHost_::tryHandleSelect(HASelect *sender, int8_t index) +{ + if (sender == timerBuzzer) + { + // Route through parseCommand: the HA select re-enters the + // control surface like every other edit, so it gets atomic-reject + // validation and the per-enum codec wire spelling — no deep setter here. + TimerManager.timerHaApply(TimerHaEntity::Buzzer, String(index)); + // Echo the canonical live mode back (snap-back to last valid on a reject). + sender->setState((int8_t)TimerManager.getBuzzerMode()); + return true; + } + if (sender == timerFinishedSel) + { + TimerManager.timerHaApply(TimerHaEntity::Finished, String(index)); + sender->setState((int8_t)TimerManager.getFinishedMode()); + return true; + } + if (sender == timerSyncTargetsSel) + { + // Dynamic select: resolve the chosen option index through the + // CURRENT peer-id list to its sync_targets value (Off -> "", All -> "all", a + // peer option -> that id), then route through parseCommand for the + // bespoke validator + NVS persistence. Echo the canonical applied value back + // against the same list (Off/All/peer, or unknown for a multi-ID CSV / an id + // no longer present — the read-only attribute stays authoritative). sync_targets + // never propagates. + String ids[kSyncTargetPeerCap]; + const char *ptrs[kSyncTargetPeerCap]; + char opts[kSyncTargetOptsCap]; + size_t n = buildCurrentSyncTargetsOptions(ids, ptrs, kSyncTargetPeerCap, + opts, sizeof(opts)); + char val[40]; + if (timerSyncTargetsValueForIndex(index, ptrs, n, val, sizeof(val))) + TimerManager.timerHaApply(TimerHaEntity::SyncTargets, val); + sender->setState(timerSyncTargetsIndexForValue(TIMER_SYNC_TARGETS.c_str(), ptrs, n)); + return true; + } + return false; +} + +bool TimerHaHost_::carriersReady() const { return timerDuration != nullptr; } + +const char *TimerHaHost_::entityId(TimerHaEntity slot) const { return timerHaId(slot); } + +TimerHaHost_ TimerHaHost; +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerHaHost.h b/src/TimerHaHost.h new file mode 100644 index 00000000..ca8a95ab --- /dev/null +++ b/src/TimerHaHost.h @@ -0,0 +1,55 @@ +#ifndef TimerHaHost_h +#define TimerHaHost_h + +#include +#include "TimerHa.h" + +// TimerHaHost owns the Timer's Home Assistant *carrier* lifecycle: the ten HA +// discovery entities (their pointers and resolved unique ids), their construction +// and teardown, the dedicated Timer duration text callback, and the Timer branches +// of the shared ArduinoHA select/switch/button callbacks (reached via tryHandle*). +// It is a device-bound adapter — it constructs real ArduinoHA objects against the +// HADevice/HAMqtt globals still owned by MQTTManager (reached via extern), exactly +// like MQTTManager.cpp. The pure builder half lives in TimerHa (topics, descriptors, +// options, haRegistrationAtCap). + +struct TimerHaHost_ +{ + // Resolve the carrier ids and (when SHOW_TIMER) construct/register the carriers. + // Runs at the same point in HA setup as before, so the ArduinoHA registration + // order — and therefore the entity-cap drop order — is unchanged. + void setup(); + // Publish the Timer wire artifacts + attribute groups on MQTT (re)connect. + // Also consumes the pending discovery-cleanup latch reconcile() set (see below). + void onConnected(); + // Reconcile the SHOW_TIMER setting against its persisted last-seen value at boot, + // before MQTT connects: if the timer was toggled off while powered down, latch a + // one-shot discovery cleanup that onConnected() flushes once MQTT is up. Persists + // the new last-seen value when it changed. Called from the device loop. + void reconcile(); + // SHOW_TIMER false->true: create carriers if missing, publish discovery + values. + void enable(); + // SHOW_TIMER true->false: prune discovery config and clear the retained attr bags. + void remove(); + + // Re-publish the dynamic Targets select's discovery when peer-registry membership + // changes, debounced. Called every device loop; a no-op when the + // carriers are absent or MQTT is down. See the debounce state in TimerHaHost.cpp. + void refreshTargets(unsigned long nowMs); + + // Each returns true iff sender is a Timer carrier; when true the host performs the + // route-through-timerHaApply + snap-back echo the shared callbacks did before. + bool tryHandleSelect(HASelect *sender, int8_t index); + bool tryHandleSwitch(bool state, HASwitch *sender); + bool tryHandleButton(HAButton *sender); + + // Accessors for the Timer wire seam that deliberately stays in MQTTManager + //: whether the carriers exist, and a carrier's resolved unique id. + // They are the seam's surface, not a transition step. + bool carriersReady() const; + const char *entityId(TimerHaEntity slot) const; +}; + +extern TimerHaHost_ TimerHaHost; + +#endif diff --git a/src/TimerManager.cpp b/src/TimerManager.cpp new file mode 100644 index 00000000..523f2c28 --- /dev/null +++ b/src/TimerManager.cpp @@ -0,0 +1,943 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerManager.h" +#include "TimerSettings.h" +#include "TimerCommand.h" // the pure atomic-reject command plan (classify) +#include "TimerHa.h" +#include "SyncEnvelope.h" // wire envelope + pure inbound receive gate +#include "Globals.h" +#include "PeripheryManager.h" +#include "DisplayManager.h" +#include "MQTTManager.h" +#include "MenuManager.h" +#include "ServerManager.h" +#include +#include +#include + +namespace { + // Sized to hold the full config snapshot (~18 keys) plus the _sync envelope on + // the propagation surface, with headroom for ArduinoJson's larger 64-bit + // slots. The HTTP/MQTT control surfaces never approach it. + constexpr uint16_t kTimerCmdJsonSize = 2048; + + const char *FALLBACK_END_RTTTL = "timer:d=4,o=5,b=120:c,8p,c,8p,c"; + const char *FALLBACK_TICK_RTTTL = "tick:d=16,o=6,b=200:c"; + + // The config-block keys that stay member-backed (B1 boundary) now + // live in TIMER_MEMBER_CONFIG_DESCS (TimerSettings.cpp), the config block's second + // table. Validation, apply, snapshot-emit and the broadcast trigger all loop that + // one table, so they cannot drift apart. + + // The one home for the wire-refresh first-appearance dedup. Walk `count` rows, + // project each to a key via keyOf(i), and invoke fn(key) once per DISTINCT key in + // first-appearance order. publishAll{AttributeGroups,Wire} and clearAllAttributeGroups + // differ only in the key projected (carrier enum vs publish-hook pointer) and the + // per-distinct action, so the O(n^2) dedup rule lives here alone -- a new carrier or + // shared hook is picked up by all three paths automatically. Host-compatible. + template + void forEachDistinct(size_t count, KeyOf keyOf, Fn fn) + { + for (size_t i = 0; i < count; ++i) + { + auto key = keyOf(i); + bool seen = false; + for (size_t j = 0; j < i && !seen; ++j) + seen = (keyOf(j) == key); + if (!seen) fn(key); + } + } +} + +static Preferences timerPrefs; + +TimerManager_ &TimerManager_::getInstance() +{ + static TimerManager_ instance; + return instance; +} + +TimerManager_ &TimerManager = TimerManager_::getInstance(); + +void TimerManager_::loadMelodiesCached() +{ + const char *endName = TIMER_MELODY_END.length() > 0 ? TIMER_MELODY_END.c_str() : "timer_end"; + const char *tickName = TIMER_MELODY_TICK.length() > 0 ? TIMER_MELODY_TICK.c_str() : "timer_tick"; + endRtttl = PeripheryManager.resolveRtttl(endName, FALLBACK_END_RTTTL); + tickRtttl = PeripheryManager.resolveRtttl(tickName, FALLBACK_TICK_RTTTL); +} + +// One-shot override. captureSnapshot records the SAVED config +// before a save:false command applies on top of it; restoreSnapshot writes it back +// when the timer returns to Idle. The table half (Family A inSnapshot rows) is captured +// generically over TIMER_SETTINGS_DESCS; the member-backed half (Family B) is captured as +// one TimerMemberConfig value (the two helpers below are the single home of its field +// list); the run-state duration and the resolved melody RAM are TimerManager's own members +// captured directly. Restore writes members directly (no setter), so it triggers no +// persist/publish side effects — carriers reported saved values throughout. +// The single home of the member-half field list: read live -> value. +// The NVS string key for each state's icon slot, indexed by TimerState. Shared by +// the load (setup) and save (persist) loops so the two cannot drift apart. +static const char *const kIconNvsKeys[kTimerStateCount] = { + "ICON_IDLE", "ICON_RUN", "ICON_PAUSE", "ICON_FIN"}; + +TimerMemberConfig TimerManager_::snapshotMemberConfig() const +{ + TimerMemberConfig c{buzzerMode, finishedMode}; + for (size_t i = 0; i < kTimerStateCount; ++i) c.iconByState[i] = iconByState[i]; + return c; +} + +// ...and value -> live, RAW: no persist, no publish, no broadcast. The override revert +// and the honest-observation swap both need exactly this side-effect-free write. +void TimerManager_::restoreMemberConfig(const TimerMemberConfig &c) +{ + buzzerMode = c.buzzer; + finishedMode = c.finished; + for (size_t i = 0; i < kTimerStateCount; ++i) iconByState[i] = c.iconByState[i]; +} + +void TimerManager_::captureSnapshot() +{ + timerSettingsCaptureSnapshot(_snapTable); + _snapMember = snapshotMemberConfig(); + _snapDuration = durationSec; // run-state: reverts with the override, outside the member half + _snapEndRtttl = endRtttl; // resolved melody RAM: ditto (the saved NAME is a table key) + _snapTickRtttl = tickRtttl; +} + +void TimerManager_::restoreSnapshot() +{ + timerSettingsRestoreSnapshot(_snapTable); + restoreMemberConfig(_snapMember); + durationSec = _snapDuration; + endRtttl = _snapEndRtttl; + tickRtttl = _snapTickRtttl; +} + +// The single revert seam shared by reset() and the tick auto-clear transition: if a +// one-shot override is active, restore the saved config and clear the flag. A no-op +// otherwise, so the normal return-to-Idle paths are unaffected. +void TimerManager_::returnToIdle() +{ + if (!_overrideActive) return; + restoreSnapshot(); + _overrideActive = false; +} + +// Honest observation carriers: swap the SAVED config block into live +// storage for the duration of a carrier projection, then restore the effective +// (one-shot) values. Only the config-block state the projections read is swapped — +// the table inSnapshot rows (sync_* excluded by construction) and the member-backed +// half (buzzer/finished/icons). Run-state (duration) and the resolved melody RAM are +// not touched. No-op when no override is active, or when enable is false (the caller +// wants the EFFECTIVE view even under an override, e.g. buildConfigSnapshot(View::Effective)). +TimerManager_::SavedConfigScope::SavedConfigScope(const TimerManager_ &t, bool enable) + : tm(const_cast(t)), active(enable && t._overrideActive) +{ + if (!active) return; + // Stash the effective (one-shot) config block, then present the saved config. + timerSettingsCaptureSnapshot(effTable); + effMember = tm.snapshotMemberConfig(); + + timerSettingsRestoreSnapshot(tm._snapTable); + tm.restoreMemberConfig(tm._snapMember); +} + +TimerManager_::SavedConfigScope::~SavedConfigScope() +{ + if (!active) return; + timerSettingsRestoreSnapshot(effTable); + tm.restoreMemberConfig(effMember); +} + +void TimerManager_::setup() +{ + timerPrefs.begin("timer", false); + durationSec = timerPrefs.getUInt("DUR", 300); + buzzerMode = (BuzzerMode)timerPrefs.getUChar("BUZ", (uint8_t)BuzzerMode::End); + finishedMode = (FinishedMode)timerPrefs.getUChar("FIN", (uint8_t)FinishedMode::AutoClear); + for (size_t i = 0; i < kTimerStateCount; ++i) + iconByState[i] = timerPrefs.getString(kIconNvsKeys[i], ""); + timerPrefs.end(); + + // dev.json per-state overrides (unchanged keys), indexed by TimerState. + const String *const iconOverrides[kTimerStateCount] = { + &TIMER_ICON_IDLE, &TIMER_ICON_RUNNING, &TIMER_ICON_PAUSED, &TIMER_ICON_FINISHED}; + for (size_t i = 0; i < kTimerStateCount; ++i) + if (iconOverrides[i]->length() > 0) iconByState[i] = *iconOverrides[i]; + + if (durationSec < 1) durationSec = 1; + if (TIMER_MAX_DURATION > 0 && durationSec > TIMER_MAX_DURATION) durationSec = TIMER_MAX_DURATION; + remainingSec = durationSec; + state = TimerState::Idle; + _suspendPersist = false; + _dirty = false; // just loaded from NVS: RAM matches it, nothing pending + + // A (re)boot knows no peers and has emitted no beacon yet — the peer registry is + // pure RAM/LAN-derived state, repopulated by inbound beacons. + _registry.setOwnId(uniqueID); + _registry.clear(); + _lastPresenceMs = 0; + _presenceEverSent = false; + + // A (re)boot has applied no sync command yet; the dedup set is pure RAM + //, repopulated by inbound commands. + _seen.clear(); + + loadMelodiesCached(); +} + +void TimerManager_::persist() +{ + timerPrefs.begin("timer", false); + timerPrefs.putUInt("DUR", durationSec); + timerPrefs.putUChar("BUZ", (uint8_t)buzzerMode); + timerPrefs.putUChar("FIN", (uint8_t)finishedMode); + for (size_t i = 0; i < kTimerStateCount; ++i) + timerPrefs.putString(kIconNvsKeys[i], iconByState[i]); + timerPrefs.end(); +} + +void TimerManager_::persistIfDirty() +{ + if (_suspendPersist) + { + _dirty = true; + return; + } + persist(); + _dirty = false; +} + +String TimerManager_::validateIconName(const String &name) +{ + // Coercing wrapper around the family's char-rule: accepted name passes + // through (empty = clear), a rejected one coerces to "" (and logs). Single source + // of the char-rule is timerIsValidIconName, so this cannot drift from the parser. + if (timerIsValidIconName(name)) return name; + if (DEBUG_MODE) DEBUG_PRINTLN("timer: icon name rejected"); + return String(""); +} + +const String &TimerManager_::getIconForState(TimerState s) const +{ + // Running/Paused/Finished fall back to Idle when their own slot is empty. + const String &own = iconByState[(size_t)s]; + if (s != TimerState::Idle && own.length() == 0) return iconByState[(size_t)TimerState::Idle]; + return own; +} + +void TimerManager_::setIcon(TimerState s, const String &name, bool publish) +{ + String n = validateIconName(name); + if (name.length() > 0 && n.length() == 0) return; // reject-guard: bad non-empty name is ignored + String &slot = iconByState[(size_t)s]; + if (slot == n) return; + slot = n; + persistIfDirty(); + if (publish) publishIcons(); +} + +void TimerManager_::publishIcons() +{ + // The four icon rows declare ONE shared aggregate publish hook, + // so dispatching any icon key reaches the same declaration. + timerMemberConfigPublish("icon_idle"); +} + +uint32_t TimerManager_::computeCurrentRemaining() const +{ + if (state != TimerState::Running) return remainingSec; + unsigned long elapsedMs = millis() - runStartMs; + uint32_t elapsedSec = elapsedMs / 1000UL; + if (elapsedSec >= runStartRemainingSec) return 0; + return runStartRemainingSec - elapsedSec; +} + +const char *TimerManager_::getStateString() const +{ + switch (state) + { + case TimerState::Idle: return "idle"; + case TimerState::Running: return "running"; + case TimerState::Paused: return "paused"; + case TimerState::Finished: return "finished"; + } + return "idle"; +} + +// Canonical wire spellings are a single row read from the per-enum codec table +// (src/TimerEnums.cpp) -- the enum value is the row index. +const char *TimerManager_::buzzerModeString() const +{ + return buzzerCodec(buzzerMode).wire; +} + +const char *TimerManager_::finishedModeString() const +{ + return finishedCodec(finishedMode).wire; +} + +String TimerManager_::getStateJson() const +{ + // Bumped from the old 512-byte fixed buffer to the shared command-size + // constant the snapshot/broadcast paths use, to hold the added config mirror + // (~20 keys incl. two melodies, a CSV sync_targets, four icon names). + DynamicJsonDocument doc(kTimerCmdJsonSize); + uint32_t remaining = computeCurrentRemaining(); + doc["state"] = getStateString(); + doc["enabled"] = (bool)SHOW_TIMER; + doc["remaining"] = remaining; + doc["remaining_str"] = timerFormatHMS(remaining); + doc["duration"] = durationSec; + doc["duration_str"] = timerFormatHMS(durationSec); + doc["buzzer"] = buzzerModeString(); + doc["finished"] = finishedModeString(); + + // Persisted-config mirror: the complete two-table dump under a + // nested `config` object, so an HTTP-only client reads back everything it can + // POST. Built in a temp doc by the pure table-walking projection, then + // deep-copied in -- duration stays top-level only (run-state, not config). + DynamicJsonDocument cfg(kTimerCmdJsonSize); + // During a one-shot override the `config` mirror reports the SAVED config, while + // the top-level run-state above stays effective. + withConfigView(View::Saved, [&] { timerBuildFullConfig(cfg); }); + doc["config"] = cfg.as(); + + String out; + serializeJson(doc, out); + return out; +} + +// Apply the run-state bookkeeping a returned Transition names. The +// pure engine decides the phase change; this owns the state mutation — including +// enterRunning's runStart* capture and the one-shot override restore behind +// ToIdle — so the override store stays in the singleton. Runs +// BEFORE the effects, so the publishes carry the new phase/remaining. +void TimerManager_::applyTransition(const TimerRuntime::Result &r, unsigned long now, + const TimerRuntime::Inputs &in) +{ + switch (r.transition) + { + case TimerRuntime::Transition::ToRunning: + // enterRunning: a fresh start (from Idle/Finished) loads the full duration; + // a resume (from Paused) keeps the frozen remaining. `state` is still the + // prior phase here, so it decides which. + if (state != TimerState::Paused) remainingSec = in.durationSec; + state = TimerState::Running; + runStartMs = now; + runStartRemainingSec = remainingSec; + runDurationSec = in.durationSec; // progress-bar denominator (buffers a mid-run duration edit) + lastPublishMs = now; + break; + case TimerRuntime::Transition::ToPaused: + remainingSec = in.newRemaining; // freeze the wall-clock remaining + state = TimerState::Paused; + break; + case TimerRuntime::Transition::ToFinished: + state = TimerState::Finished; + remainingSec = 0; + enteredFinishedMs = now; + lastRealertMs = now; + break; + case TimerRuntime::Transition::ToIdle: + returnToIdle(); // one-shot: restore saved config (override store lives here) + state = TimerState::Idle; + remainingSec = in.durationSec; + break; + case TimerRuntime::Transition::IdleReload: + remainingSec = in.durationSec; // duration edited while Idle; phase stays Idle + break; + case TimerRuntime::Transition::None: + break; + } +} + +// Thin adapter for the input-driven lifecycle verbs: resolve the +// inputs, let TimerRuntime::step() decide the transition + ordered effects, apply +// the run-state bookkeeping, then execute the effects. The same shape tick() uses, +// so start/pause/reset/setDuration and the countdown step share one seam. +void TimerManager_::runCommand(TimerRuntime::Command cmd) +{ + unsigned long now = millis(); + TimerRuntime::Inputs in = buildInputs(); + in.command = cmd; + TimerRuntime::State st{state, remainingSec, lastPublishMs, enteredFinishedMs, lastRealertMs}; + TimerRuntime::Result r = TimerRuntime::step(st, now, in); + applyTransition(r, now, in); + for (const TimerRuntime::Effect &e : r.effects) applyEffect(e); +} + +// Resolve the environment gates + config the pure engine reads into an Inputs +// value. tick() shares this across the Running and Finished branches, +// so the globals/singletons the engine deliberately doesn't name are touched in +// exactly one place. +TimerRuntime::Inputs TimerManager_::buildInputs() const +{ + TimerRuntime::Inputs in; + in.durationSec = durationSec; + in.newRemaining = computeCurrentRemaining(); + in.countdownArmed = SOUND_ACTIVE && buzzerMode == BuzzerMode::Countdown && tickRtttl.length() > 0; + in.isPlaying = PeripheryManager.isPlaying(); + in.countdownSeconds = TIMER_COUNTDOWN_SECONDS; + in.publishInterval = TIMER_PUBLISH_INTERVAL; + in.navigationFree = !GAME_ACTIVE && !BLOCK_NAVIGATION && !MenuManager.inMenu; + in.matrixOff = MATRIX_OFF; + in.brightness = BRIGHTNESS; + in.playEndTone = SOUND_ACTIVE && buzzerMode != BuzzerMode::Off && endRtttl.length() > 0; + in.finishedMode = finishedMode; + in.finishedHold = TIMER_FINISHED_HOLD; + in.realertInterval = TIMER_REALERT_INTERVAL; + in.realertArmed = SOUND_ACTIVE && buzzerMode != BuzzerMode::Off; + in.realertToneSet = endRtttl.length() > 0; + return in; +} + +// Executes one effect against the hardware managers / wire seam. The full effect +// vocabulary is handled so the later slices can lean on it; the Finished path emits +// only a subset. +void TimerManager_::applyEffect(const TimerRuntime::Effect &e) +{ + switch (e.kind) + { + case TimerRuntime::EffectKind::PublishState: + publishState(); + break; + case TimerRuntime::EffectKind::PublishRemaining: + publishRemaining(); + break; + case TimerRuntime::EffectKind::SwitchToTimerApp: + { + String j = "{\"name\":\"Timer\",\"fast\":true}"; + DisplayManager.switchToApp(j.c_str()); + break; + } + case TimerRuntime::EffectKind::SetBrightness: + DisplayManager.setBrightness(e.brightness); + break; + case TimerRuntime::EffectKind::PlayTone: + PeripheryManager.playRTTTLString(e.tone == TimerRuntime::Tone::End ? endRtttl : tickRtttl); + break; + case TimerRuntime::EffectKind::StopSound: + PeripheryManager.stopSound(); + break; + } +} + +void TimerManager_::start() { runCommand(TimerRuntime::Command::Start); } + +void TimerManager_::pause() { runCommand(TimerRuntime::Command::Pause); } + +void TimerManager_::reset() { runCommand(TimerRuntime::Command::Reset); } + +// The run-state seam: the verb + its peer mirror, paired in one +// place. broadcastRunState self-no-ops under _remoteApply and sync-off, so this is +// safe to call unconditionally from any locally-driven path. +void TimerManager_::runStateAction(TimerCommand::Action a) +{ + switch (a) + { + case TimerCommand::Action::Start: start(); broadcastRunState("start"); break; + case TimerCommand::Action::Pause: pause(); broadcastRunState("pause"); break; + case TimerCommand::Action::Reset: reset(); broadcastRunState("reset"); break; + case TimerCommand::Action::None: break; // no verb, no packet + } +} + +void TimerManager_::setDuration(uint32_t seconds) +{ + // Clamp + no-op guard stay in the adapter (input validation, not a transition); + // durationSec must be committed before runCommand so buildInputs() feeds the + // engine the NEW duration (the Idle reload / paused-edit reset load it into + // remaining). The engine decides the phase transition; the duration + // persist + republish are unconditional adapter concerns. + if (seconds < 1) seconds = 1; + if (TIMER_MAX_DURATION > 0 && seconds > TIMER_MAX_DURATION) seconds = TIMER_MAX_DURATION; + if (durationSec == seconds) return; + durationSec = seconds; + runCommand(TimerRuntime::Command::SetDuration); // Idle: reload remaining; Paused: reset to Idle (US6) + persistIfDirty(); + publishDuration(); +} + +void TimerManager_::setBuzzerMode(BuzzerMode m, bool persist) +{ + if (buzzerMode == m) return; + buzzerMode = m; + if (persist) persistIfDirty(); + else _dirty = true; // deferred edit: pending until the next PersistBatch commit + publishBuzzerMode(); +} + +void TimerManager_::setFinishedMode(FinishedMode m, bool persist) +{ + if (finishedMode == m) return; + finishedMode = m; + if (persist) persistIfDirty(); + else _dirty = true; // deferred edit: pending until the next PersistBatch commit + publishFinishedMode(); +} + +// Thin adapter over the pure engine: resolve the inputs, let +// TimerRuntime::step() decide the ordered effects + the run-state bookkeeping for +// both the Running and Finished branches, then apply the bookkeeping and execute +// the effects in order. The state mutation (and the one-shot override restore +// behind ToIdle) stays here; effects run after it so the publishes carry the new +// phase/remaining, exactly as the imperative tick did. +void TimerManager_::tick() +{ + if (state != TimerState::Running && state != TimerState::Finished) return; + + unsigned long now = millis(); + TimerRuntime::Inputs in = buildInputs(); + TimerRuntime::State st{state, remainingSec, lastPublishMs, enteredFinishedMs, lastRealertMs}; + TimerRuntime::Result r = TimerRuntime::step(st, now, in); + + // Tick-only bookkeeping the transition switch does not own: the Running branch + // always advances remaining to the freshly-computed value and re-anchors the + // publish throttle; the Finished branch advances the re-alert anchor. The phase + // change itself (ToFinished / auto-clear ToIdle) is applied by applyTransition, + // the same seam start/pause/reset/setDuration use. + if (state == TimerState::Running) + { + remainingSec = in.newRemaining; + if (r.publishFired) lastPublishMs = now; + } + else if (r.realertFired) // Finished + { + lastRealertMs = now; + } + + applyTransition(r, now, in); + + for (const TimerRuntime::Effect &e : r.effects) applyEffect(e); +} + +TimerCmdResult TimerManager_::parseCommand(const char *json) +{ + if (!SHOW_TIMER) return TimerCmdResult::Disabled; + if (json == nullptr || json[0] == '\0') return TimerCmdResult::BadJson; + + DynamicJsonDocument doc(kTimerCmdJsonSize); + auto err = deserializeJson(doc, json); + if (err) + { + if (DEBUG_MODE && err == DeserializationError::NoMemory) + DEBUG_PRINTLN("timer: parseCommand NoMemory"); + return TimerCmdResult::BadJson; + } + + // -- Validation: the whole atomic-reject pass runs once in the pure + // TimerCommand::classify, mutating nothing. The shell + // fills the Context from the globals it owns (the saved ceiling + _remoteApply) + // and drives apply from the returned Plan -- the packet is never re-read below. -- + TimerCommand::Plan plan = + TimerCommand::classify(doc.as(), + TimerCommand::Context{TIMER_MAX_DURATION, _remoteApply}); + if (!plan.ok) return TimerCmdResult::BadField; // first invalid field; nothing applied + + // -- Command is known-good: only now disturb device state. -- + // One-shot override: before applying, snapshot the saved config so + // returnToIdle() can restore it. Only the first one-shot in a run captures (latest- + // command-wins, single snapshot); a later one-shot applies on top of the same baseline. + if (plan.oneShot && !_overrideActive) + { + captureSnapshot(); + _overrideActive = true; + } + + // -- Apply, inside ONE PersistBatch window; its scope exit commits the whole config + // (both NVS namespaces, each at most once). Table rows FIRST, so a raised + // TIMER_MAX_DURATION lands before setDuration() re-clamps against the GLOBAL + // ceiling (a duration classify accepted against the staged + // ceiling would otherwise be silently clamped to the old one). Then duration + // (run-state, B1), then the member-backed applies via their publish-aware setters. -- + bool melodyChanged = false; + { + PersistBatch batch(*this); + if (plan.oneShot) batch.setTransient(); // one-shot: scope exit writes nothing to flash + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + if (!plan.tablePresent[i]) continue; + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (timerSettingStore(d, plan.tableStaged[i])) batch.markTableDirty(); + if (strcmp(d.cmdKey, "melody_tick") == 0 || strcmp(d.cmdKey, "melody_end") == 0) melodyChanged = true; + } + if (plan.haveDuration) setDuration(plan.durationSec); // pre-validated in range + for (size_t i = 0; i < TIMER_MEMBER_CONFIG_DESC_COUNT; ++i) + if (plan.memberPresent[i]) TIMER_MEMBER_CONFIG_DESCS[i].apply(plan.memberStaged[i]); + } + + if (melodyChanged) loadMelodiesCached(); + // Inline melodies: use the validated tune directly as the resolved RAM, + // after any bare-name re-resolve above so it wins. The saved name globals are + // untouched (config mirror keeps the saved name); the snapshot captured the saved- + // resolved RAM, so returnToIdle() reverts these on return to Idle. + if (plan.haveInlineEnd) endRtttl = plan.inlineEnd; + if (plan.haveInlineTick) tickRtttl = plan.inlineTick; + + // Rebaseline (story 21): a normal (save:true) config command arriving + // during an active one-shot run commits the live config — including prior one-shot + // values — as the new saved baseline and ends the override, so a later revert leaves + // the promoted truth in place. A pure action/duration command does NOT rebaseline (it + // carries no config to commit), so the override survives to revert. configInCommand + // (table inSnapshot half OR member half; sync_* and run-state excluded) is computed + // by classify. + if (!plan.oneShot && _overrideActive && plan.configInCommand) + { + persist(); // member half: full live state -> "timer" NVS + saveSettings(); // table half: full live state -> "awtrix" NVS + _overrideActive = false; + } + + // Settings projected as read-only HA attributes: republish each affected + // carrier's bag when any of its mapped keys was in the command (classify computed the + // dirty set). Fires on the remote-apply path too (not _remoteApply-gated), keeping + // each synced peer's HA attributes consistent. Suppressed under a + // one-shot command so the retained bags keep reporting the saved config. + if (!plan.oneShot) + { + for (size_t c = 0; c < (size_t)TimerHaEntity::COUNT; ++c) + if (plan.attrCarrierDirty[c]) publishAttributeGroup((TimerHaEntity)c); + } + + // Run-state dispatch rides the runStateAction seam: the verb and its + // peer mirror are paired there, so this path can't emit one without the other. + // The propagation contract is the run-scoped config mirror: a `start` emits + // the ONE combined packet (action + duration + effective config snapshot), + // pause/reset propagate run-state only, and a config/bare-duration edit + // propagates NOTHING; broadcastRunState itself no-ops under _remoteApply + // (one-hop) and sync-off. The broadcast deliberately fires BEFORE the + // switch-to-app below: the broadcast reads state/duration/effective config, + // switchToApp touches display only. + bool fromIdle = (state == TimerState::Idle); + runStateAction(plan.action); + if (plan.action == TimerCommand::Action::Start && fromIdle && + !GAME_ACTIVE && !BLOCK_NAVIGATION) + { + String j = "{\"name\":\"Timer\"}"; + DisplayManager.switchToApp(j.c_str()); + } + + return TimerCmdResult::Ok; +} + +// HA control adapter: each HA timer callback re-enters the control +// surface through parseCommand, exactly as the propagation surface does, rather +// than poking a deep setter. The minimal JSON each entity builds is the SAME shape +// the {prefix}/timer MQTT topic accepts, so HA edits inherit atomic-reject +// validation, run-state propagation, and the per-enum codec spellings for free — +// nothing about duration parsing or enum encoding is duplicated in the HA layer. +TimerCmdResult TimerManager_::timerHaApply(TimerHaEntity entity, const String &rawValue) +{ + StaticJsonDocument<128> doc; + switch (entity) + { + case TimerHaEntity::Buzzer: + { + // The select callback hands us the chosen option index; map it through the + // per-enum codec so the emitted wire string is the one + // parseCommand accepts — the two cannot drift. + long idx = rawValue.toInt(); + if (idx < 0 || (size_t)idx >= TIMER_BUZZER_CODEC_COUNT) return TimerCmdResult::BadField; + doc["buzzer"] = TIMER_BUZZER_CODEC[idx].wire; + break; + } + case TimerHaEntity::Finished: + { + long idx = rawValue.toInt(); + if (idx < 0 || (size_t)idx >= TIMER_FINISHED_CODEC_COUNT) return TimerCmdResult::BadField; + doc["finished"] = TIMER_FINISHED_CODEC[idx].wire; + break; + } + case TimerHaEntity::Duration: + // The raw HH:MM:SS text rides straight into parseCommand, which owns the + // parse/validate (timerParseHMS + range, reject-not-clamp). On a non-Ok result + // the caller echoes the canonical live value back (snap-back). + doc["duration"] = rawValue; + break; + case TimerHaEntity::Start: doc["action"] = "start"; break; + case TimerHaEntity::Pause: doc["action"] = "pause"; break; + case TimerHaEntity::Reset: doc["action"] = "reset"; break; + case TimerHaEntity::SyncFollow: + // The switch callback hands us the new bool ("1"/"0"); emit the strict + // bool parseCommand's sync_follow validator (TcCheck::Bool) accepts. Local + // identity (inSnapshot=false), so it persists but never propagates. + doc["sync_follow"] = (rawValue.toInt() != 0); + break; + case TimerHaEntity::SyncTargets: + // The dynamic select hands us the RESOLVED sync_targets value, + // not an index: "" (Off), "all" (All), or a discovered peer id. MQTTManager + // maps the chosen option index through the CURRENT id list before calling, so + // the option set tracks the registry. The bespoke sync_targets validator + // (parseSyncTargets) rejects a malformed id (atomic-reject parity). Local + // identity (inSnapshot=false): persists but never propagates. + doc["sync_targets"] = rawValue; + break; + default: + return TimerCmdResult::BadField; // not a control entity + } + + String json; + serializeJson(doc, json); + return parseCommand(json.c_str()); +} + +void TimerManager_::onShowTimerChange(bool prev, bool now) +{ + if (prev && !now) reset(); +} + +// State is run-state, not a member-config row, so it goes through the wire +// seam directly: the exact (topic, payload) the broker receives, byte-identical +// to the retired HASensor::setValue path. +void TimerManager_::publishState() +{ + MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::State).c_str(), + getStateString()); +} +// Remaining is run-state too: straight through the seam, payload a +// plain decimal string — byte-identical to the retired HASensorNumber +// (PrecisionP0) setValue path. +void TimerManager_::publishRemaining() +{ + MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::Remaining).c_str(), + String(remainingSec).c_str()); +} +// Duration is run-state too: straight through the seam, payload the +// trimmed-HMS clock string — byte-identical to the retired HAText::setState path. +void TimerManager_::publishDuration() +{ + MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::Duration).c_str(), + timerFormatHMS(durationSec).c_str()); +} +// The enum keys are member-config rows: dispatch through the row's +// declared publish hook so validate/apply/emit/publish stay co-located and the +// table is the single definition of how each key goes out on the wire. +void TimerManager_::publishBuzzerMode() { timerMemberConfigPublish("buzzer"); } +void TimerManager_::publishFinishedMode() { timerMemberConfigPublish("finished"); } + +// A carrier's read-only JSON attribute object: the bag is +// built table-driven by timerBuildAttributeGroup, serialized, and ridden onto the +// carrier's json_attr_t topic by the wire seam — the same path every other Timer +// value takes. HA reads it because the TimerHaHost carrier build opted the carrier +// select into json attributes (setJsonAttributes), so the discovery config advertises +// this topic. Retained means HA repopulates after a restart for free. A carrier +// with no mapped rows yields an empty bag and publishes nothing. +void TimerManager_::publishAttributeGroup(TimerHaEntity carrier) +{ + // 512: the state sensor's bag is the largest (eight config-view keys incl. + // two strings), which overflows 256 on a 64-bit host. + DynamicJsonDocument doc(512); + // A republish (e.g. on reconnect) during a one-shot override serializes the SAVED + // config, so HA attribute bags never show transient one-off values. + withConfigView(View::Saved, [&] { timerBuildAttributeGroup(carrier, doc); }); + if (doc.as().size() == 0) return; + String payload; + serializeJson(doc, payload); + MQTTManager.publishTimerWire(MQTTManager.timerWireAttrTopic(carrier).c_str(), payload.c_str()); +} + +// Every distinct carrier's attribute object, each published once. Carriers are +// deduped by first appearance in the attribute-group table (a carrier owns +// several rows), mirroring publishAllWire's hook dedupe. +void TimerManager_::publishAllAttributeGroups() +{ + forEachDistinct(TIMER_ATTR_GROUP_DESC_COUNT, + [](size_t i) { return TIMER_ATTR_GROUP_DESCS[i].carrier; }, + [this](TimerHaEntity carrier) { publishAttributeGroup(carrier); }); +} + +// Teardown mirror of publishAllAttributeGroups: empty the retained json_attr_t +// topic of every distinct carrier so disabling the Timer (discovery teardown) +// leaves no orphaned attribute object on the broker. Same carrier +// dedupe and same wire seam — an empty retained payload is the MQTT clear. +// publishTimerWire's creation-sentinel gate makes this no-op when no entity ever +// existed (nothing was advertised, so nothing to clear). +void TimerManager_::clearAllAttributeGroups() +{ + forEachDistinct(TIMER_ATTR_GROUP_DESC_COUNT, + [](size_t i) { return TIMER_ATTR_GROUP_DESCS[i].carrier; }, + [](TimerHaEntity carrier) { + MQTTManager.publishTimerWire(MQTTManager.timerWireAttrTopic(carrier).c_str(), ""); + }); +} + +// Full wire refresh. The run-state trio is a fixed +// set (state/remaining/duration are run-state, not table rows); the config half +// is DERIVED from TIMER_MEMBER_CONFIG_DESCS, so a row added with a publish hook +// is republished on connect / discovery-enable without touching this function. +// Hooks are deduped by pointer (forEachDistinct) — the four icon rows share one +// aggregate hook, whose JSON must hit the wire exactly once; the null-hook rows +// (key not individually published) collapse to one distinct null that the guard +// skips. Order preserved from the retired hand-listed blocks: duration, remaining, +// state, then table order. +void TimerManager_::publishAllWire() +{ + publishDuration(); + publishRemaining(); + publishState(); + forEachDistinct(TIMER_MEMBER_CONFIG_DESC_COUNT, + [](size_t i) { return TIMER_MEMBER_CONFIG_DESCS[i].publish; }, + [](void (*hook)()) { if (hook) hook(); }); +} + +// --------------------------------------------------------------------------- +// Propagation surface (device-to-device timer sync). See docs/timer.md +// "Multi-device sync". +// --------------------------------------------------------------------------- + +void TimerManager_::addSyncEnvelope(JsonObject &sync) +{ + // Thin forwarder: this class owns the monotonic _syncSeq counter (injected as + // seq); the envelope shape + target-CSV parsing live in SyncEnvelope. + SyncEnvelope::build(sync, uniqueID, ++_syncSeq, TIMER_SYNC_TARGETS); +} + +void TimerManager_::buildConfigSnapshot(JsonDocument &doc, View view) const +{ + // Config block only — never action/duration (run-state) or sync_* (local identity, + // inSnapshot=false). Two tables, one config block: TIMER_SETTINGS_DESCS' inSnapshot + // rows and TIMER_MEMBER_CONFIG_DESCS (the member-backed half, B1). + // Each table also feeds the parseCommand broadcast trigger, so the snapshot can't + // drift from what fires a broadcast. + // View::Saved masks an active one-shot override so the snapshot reports the SAVED + // config — a follower never receives transient one-off values it has no notion of + // reverting (broadcastConfig is itself suppressed during an + // override, so this is also defensive). View::Effective takes live + // storage as-is so a leader's own one-shot run mirrors to followers — the snapshot + // reports what is actually running. + withConfigView(view, [&] { + timerSettingsBuildSnapshot(doc); + timerMemberConfigBuildSnapshot(doc); + }); +} + +void TimerManager_::broadcastRunState(const char *action) +{ + if (_remoteApply) return; // one-hop: never re-emit an applied remote command + if (TIMER_SYNC_TARGETS.length() == 0) return; // sync off + + // Only an action ever reaches here — start / pause / reset. A `start` is the + // SOLE duration-bearing packet: it carries the leader's effective `duration` plus the + // leader's EFFECTIVE config snapshot, bundled into one combined packet — the + // run-scoped config mirror. Config no longer travels on a config edit, and + // a bare duration edit propagates nothing; both ride one combined packet with the + // start so a follower mirrors the leader for that run. The combined packet needs the + // full kTimerCmdJsonSize buffer (config snapshot + envelope); pause/reset stay + // run-state-only and fit a small static buffer. + bool isStart = (strcasecmp(action, "start") == 0); + + if (isStart) + { + DynamicJsonDocument doc(kTimerCmdJsonSize); + JsonObject sync = doc.createNestedObject("_sync"); + addSyncEnvelope(sync); + doc["action"] = action; + doc["duration"] = durationSec; // duration rides only with a start (defines the countdown) + // EFFECTIVE config: a leader's own one-shot run mirrors to followers, so the + // snapshot reports what is actually running. sync_* are inSnapshot=false and + // excluded by construction; inline melodies never travel (the saved bare name + // globals back the snapshot). + buildConfigSnapshot(doc, View::Effective); + + String out; serializeJson(doc, out); + ServerManager.sendTimerSync(out); + return; + } + + // pause / reset: run-state only, no duration, no config. + StaticJsonDocument<256> doc; + JsonObject sync = doc.createNestedObject("_sync"); + addSyncEnvelope(sync); + doc["action"] = action; + + String out; serializeJson(doc, out); + ServerManager.sendTimerSync(out); +} + +void TimerManager_::broadcastConfig() +{ + if (_remoteApply) return; + if (TIMER_SYNC_TARGETS.length() == 0) return; + + DynamicJsonDocument doc(kTimerCmdJsonSize); + JsonObject sync = doc.createNestedObject("_sync"); + addSyncEnvelope(sync); + buildConfigSnapshot(doc, View::Saved); // propagated config reports SAVED + + String out; serializeJson(doc, out); + ServerManager.sendTimerSync(out); +} + +void TimerManager_::applySyncCommand(const char *json) +{ + if (json == nullptr || json[0] == '\0') return; + + DynamicJsonDocument doc(kTimerCmdJsonSize); + if (deserializeJson(doc, json)) return; + + // The echo/presence/follow/target decision is a PURE function (SyncEnvelope): + // this shell only deserializes, then acts on the Decision. Dedup is + // deliberately NOT in classify — SyncSeenCache::seen() is stateful (test-and- + // record), so it stays here as the single guard before re-entry. + SyncEnvelope::Decision d = + SyncEnvelope::classify(doc.as(), {uniqueID, TIMER_SYNC_FOLLOW}); + + switch (d.kind) + { + case SyncEnvelope::Decision::HarvestPresence: + // Presence harvest: record the sender ungated, apply no + // timer state. classify already bypassed the follow/target gate. + _registry.record(d.src, millis()); + break; + + case SyncEnvelope::Decision::Apply: + if (_seen.seen(d.src, d.seq, millis())) return; // redundant copy of a burst + // Re-enter the local control surface. The send-path _remoteApply guard + // prevents re-broadcasting (one-hop); parseCommand ignores the _sync envelope. + _remoteApply = true; + parseCommand(json); + _remoteApply = false; + break; + + case SyncEnvelope::Decision::Ignore: + break; + } +} + +// --------------------------------------------------------------------------- +// Peer presence beacon. The peer SET lives in PeerRegistry +// (extracted); this class keeps only the beacon cadence + UDP send. +// --------------------------------------------------------------------------- + +void TimerManager_::broadcastPresence() +{ + // A small unconditional beacon: {_sync:{src,seq}, presence:true}. No tgt — it is + // informational, harvested ungated by every receiver. Independent of sync targets + // so even a clock that commands nobody is still discoverable. 256 matches the + // pause/reset run-state beacon buffer (the src/seq envelope routes through + // SyncEnvelope::build, which wants a touch more pool headroom on the 64-bit host). + StaticJsonDocument<256> doc; + JsonObject sync = doc.createNestedObject("_sync"); + SyncEnvelope::build(sync, uniqueID, ++_syncSeq); // bare {src,seq}: no tgt (informational) + doc["presence"] = true; + + String out; serializeJson(doc, out); + ServerManager.sendTimerSync(out); // also AP-gated at the transport (defense in depth) +} + +void TimerManager_::tickPresence(unsigned long nowMs) +{ + _registry.prune(nowMs); + + if (AP_MODE) return; // no beacon in AP mode (a standalone clock with no real LAN) + + if (!_presenceEverSent || (nowMs - _lastPresenceMs) >= kPresenceIntervalMs) + { + broadcastPresence(); + _lastPresenceMs = nowMs; + _presenceEverSent = true; + } +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerManager.h b/src/TimerManager.h new file mode 100644 index 00000000..1e6b5477 --- /dev/null +++ b/src/TimerManager.h @@ -0,0 +1,396 @@ +#ifndef TimerManager_h +#define TimerManager_h + +#include +#include + +#include "TimerEnums.h" // TimerState + BuzzerMode / FinishedMode + their codec tables +#include "TimerRuntime.h" // pure run-state engine: step() returns effects this class applies +#include "TimerHa.h" // TimerHaEntity (the HA carrier publishAttributeGroup targets) +#include "TimerSettings.h" // TcValue + TIMER_SETTINGS_DESC_CAP (one-shot override snapshot) +#include "TimerCommand.h" // TimerCommand::Action (the runStateAction verb) +#include "PeerRegistry.h" // the LAN peer set (extracted from this class) +#include "SyncSeenCache.h" // the sync dedup set (extracted from this class) + +// Result of parseCommand. All control surfaces share one validation policy +// (reject invalid input atomically); only the HTTP API surfaces this as a +// status code — MQTT ignores it. +enum class TimerCmdResult : uint8_t { Ok = 0, BadJson = 1, BadField = 2, Disabled = 3 }; + +// Globals.cpp's full "awtrix"-namespace flush (declared in Globals.h, repeated +// here so the PersistBatch guard below can perform the table-half commit). +void saveSettings(); + +// The member-backed config half (B1) as one value, mirroring TIMER_MEMBER_CONFIG_DESCS +// ({buzzer, finished, the four icon_ names}) -- the singleton fields that are NOT +// driven by the generic TIMER_SETTINGS_DESCS table. It is exactly the set the one-shot +// override snapshots and SavedConfigScope swaps, so "swap == copy this struct" holds. +// Deliberately excludes durationSec (run-state) and the resolved melody RAM +// (endRtttl/tickRtttl -- the saved melody NAME is a table key); those revert with the +// override but are not config the observation carriers project as "saved". +// Number of TimerState values (Idle/Running/Paused/Finished) — the width of the +// state-indexed icon array. TimerState has no COUNT member; this is its stand-in. +static constexpr size_t kTimerStateCount = 4; + +struct TimerMemberConfig +{ + // The explicit constructor keeps the two-value brace-init valid in C++11 + // (a class with default member initializers is not a C++11 aggregate). + TimerMemberConfig() = default; + TimerMemberConfig(BuzzerMode buzzer_, FinishedMode finished_) + : buzzer(buzzer_), finished(finished_) {} + + BuzzerMode buzzer = BuzzerMode::End; + FinishedMode finished = FinishedMode::AutoClear; + String iconByState[kTimerStateCount]; // indexed by TimerState +}; + +class TimerManager_ +{ +private: + TimerManager_() = default; + + TimerState state = TimerState::Idle; + BuzzerMode buzzerMode = BuzzerMode::End; + FinishedMode finishedMode = FinishedMode::AutoClear; + uint32_t durationSec = 300; + uint32_t remainingSec = 300; + + String iconByState[kTimerStateCount]; // indexed by TimerState (Idle/Running/Paused/Finished) + + String endRtttl; + String tickRtttl; + + unsigned long runStartMs = 0; + uint32_t runStartRemainingSec = 0; + uint32_t runDurationSec = 0; // duration in force when the current run began; the progress-bar denominator (buffers a mid-run duration edit) + unsigned long enteredFinishedMs = 0; + unsigned long lastRealertMs = 0; + unsigned long lastPublishMs = 0; + + bool _suspendPersist = false; + // Member-backed RAM state differs from the "timer" NVS namespace: set by a + // suspended persistIfDirty() inside a PersistBatch window, or by a deferred + // (persist=false) enum edit during a TIMER-menu scroll session. Cleared by + // the flush that writes it (persistIfDirty / PersistBatch scope exit). + bool _dirty = false; + + // -- Propagation surface (device-to-device timer sync) -- + // While true, an inbound sync packet is being applied via parseCommand; the + // broadcast* methods early-return so a received command is never re-emitted + // (one-hop topology). + bool _remoteApply = false; + uint32_t _syncSeq = 0; // per-command sequence; only needs uniqueness within the dedup window + + // Emit one UDP broadcast mirroring a locally-accepted action to peers. No-ops + // when sync is off (empty target list) or while applying an inbound packet + // (_remoteApply). Private: every local run-state actor goes through + // runStateAction(), so the verb+mirror pairing is not a caller obligation. + void broadcastRunState(const char *action); + + // Bounded recently-seen (src,seq) dedup set so the 3x redundant send is applied + // once. Extracted to its own self-contained module (SyncSeenCache); the + // UDP transport + parseCommand re-entry stay here. TTL-based, so a sender reboot + // (seq restart) self-clears by ageing out. + SyncSeenCache _seen; + + // -- Peer presence registry (extracted to PeerRegistry) -- + // The LAN peer set lives in its own self-contained module (PeerRegistry); this + // class keeps only the beacon cadence + UDP send. Harvested UNGATED from + // inbound presence beacons (presence is informational, not a command — it + // bypasses the follow/target gate and applies no timer state). + static constexpr unsigned long kPresenceIntervalMs = 30000; // beacon cadence + PeerRegistry _registry; + unsigned long _lastPresenceMs = 0; + bool _presenceEverSent = false; + void broadcastPresence(); // emit one {_sync,presence:true} beacon + + // -- One-shot override (save:false) -- + // A save:false command applies its config for the CURRENT RUN only: the saved + // config is snapshotted, the command applies live, and returnToIdle() restores + // the snapshot when the timer next returns to Idle (reset or auto-clear). While + // an override is active no NVS write, no config broadcast and no HA config- + // attribute republish occur. A normal (save:true) config command mid-override + // promotes the live config to the new saved baseline and ends the override. + bool _overrideActive = false; + TcValue _snapTable[TIMER_SETTINGS_DESC_CAP]; // Family A (inSnapshot rows), generic capture + TimerMemberConfig _snapMember; // Family B (member-backed half) + uint32_t _snapDuration = 300; + String _snapEndRtttl, _snapTickRtttl; + void captureSnapshot(); // record the saved config (both tables + duration + melody RAM) + void restoreSnapshot(); // write the snapshot back (no publish/persist side effects) + void returnToIdle(); // revert seam shared by reset() and the tick auto-clear transition + + // The only two places that name the member-backed config fields: read the live + // singleton state into a value (snapshot), and write a value back RAW -- no persist, + // no publish, no broadcast -- as the override revert + the honest-observation swap + // both require. Used by captureSnapshot/restoreSnapshot and SavedConfigScope. + TimerMemberConfig snapshotMemberConfig() const; + void restoreMemberConfig(const TimerMemberConfig &c); + + // Honest observation carriers. RAII: while an override is active, + // present the SAVED config block (table inSnapshot rows + member-backed half) in + // live storage so a carrier projection reads saved values, then restore the + // effective (one-shot) values on scope exit. No-op when no override is active. + // The const_cast is sound — the singleton is non-const and the swap is fully + // reverted, so wrapping a const projection method stays observably const. Run-state + // (duration) and sync_* (inSnapshot=false) are intentionally untouched. + class SavedConfigScope + { + public: + // enable=false forces a no-op even under an active override (View::Effective). + explicit SavedConfigScope(const TimerManager_ &t, bool enable = true); + ~SavedConfigScope(); + SavedConfigScope(const SavedConfigScope &) = delete; + SavedConfigScope &operator=(const SavedConfigScope &) = delete; + private: + TimerManager_ &tm; + bool active; + TcValue effTable[TIMER_SETTINGS_DESC_CAP]; + TimerMemberConfig effMember; // the effective (one-shot) member half, restored on scope exit + }; + + // Which config a serializer reports: Saved opens a SavedConfigScope so an active + // one-shot override is masked (broadcastConfig); Effective takes live + // storage as-is so a leader's own one-shot run mirrors to followers. + enum class View { Saved, Effective }; + + // The one seam every config-honesty serializer names its view through: runs + // `serialize` with live storage swapped to `view` (View::Saved opens a + // SavedConfigScope masking the one-shot override; View::Effective is a no-op). + // Callers (GET mirror, HA bags, propagated snapshot) must name a View to compile, + // so no path silently omits the scope. + template + void withConfigView(View view, Serialize &&serialize) const + { + SavedConfigScope saved(*this, view == View::Saved); + serialize(); + } + void buildConfigSnapshot(JsonDocument &doc, View view) const; // config keys only; no action/duration/sync_* + void addSyncEnvelope(JsonObject &sync); // forwards to SyncEnvelope::build (injects _syncSeq) + + uint32_t computeCurrentRemaining() const; + TimerRuntime::Inputs buildInputs() const; // resolve engine inputs from the environment + void runCommand(TimerRuntime::Command cmd); // start/pause/reset/setDuration adapter: step() + apply + void applyTransition(const TimerRuntime::Result &r, unsigned long now, + const TimerRuntime::Inputs &in); // run-state bookkeeping for a returned Transition + void applyEffect(const TimerRuntime::Effect &e); // the effects-adapter seam + void persist(); + void persistIfDirty(); + void loadMelodiesCached(); + + static String validateIconName(const String &name); + +public: + // RAII guard that makes the persist-batching window a visible lexical scope + // — the ONE commit seam shared by both batch call sites: + // parseCommand's apply block and the TIMER menu's long-press commit + // (MenuManager). While the guard lives, member-backed persistence is + // suspended; scope exit commits the whole Timer config, flushing both NVS + // namespaces at most once each: the member-backed "timer" namespace iff RAM + // holds uncommitted member state (a setter dirtied it inside the window, or + // deferred persist=false menu edits dirtied it beforehand), then the + // table-backed "awtrix" namespace (saveSettings) iff markTableDirty() was + // called. Exceptions are off on this target, so "scope exit" means the + // normal return paths. + class PersistBatch + { + public: + explicit PersistBatch(TimerManager_ &tm) : tm(tm) + { + tm._suspendPersist = true; + } + ~PersistBatch() + { + tm._suspendPersist = false; + // Transient (one-shot, save:false) window: scope exit writes NOTHING to + // flash. RAM intentionally diverges from NVS for the duration of the run + // (returnToIdle() restores it), so the pending member-half dirty flag is + // discarded rather than flushed, and the table half is left untouched. + if (transient) + { + tm._dirty = false; + return; + } + if (tm._dirty) + { + tm._dirty = false; + tm.persist(); + } + if (tableDirty) + saveSettings(); + } + // A table-backed ("awtrix"-namespace) value changed inside the window. + void markTableDirty() { tableDirty = true; } + // Mark this window one-shot: scope exit skips both NVS flushes. + void setTransient() { transient = true; } + + PersistBatch(const PersistBatch &) = delete; + PersistBatch &operator=(const PersistBatch &) = delete; + + private: + TimerManager_ &tm; + bool tableDirty = false; + bool transient = false; + }; + + static TimerManager_ &getInstance(); + void setup(); + void tick(); + + void start(); + void pause(); + void reset(); + + // The run-state seam: dispatch the verb (start/pause/reset) + // AND mirror it to peers via broadcastRunState with the matching action string — + // the pairing every accepted action must make, folded into one entry so a call + // site can't emit the verb without the mirror. Action::None is a no-op. Safe on + // every path: broadcastRunState self-no-ops under _remoteApply and sync-off. + void runStateAction(TimerCommand::Action a); + + void setDuration(uint32_t seconds); + // persist=false applies + publishes live but defers the NVS write (the TIMER + // menu's deferred-to-commit path; mirrors setIcon*'s publish flag). Every other + // caller uses the default and persists immediately. + void setBuzzerMode(BuzzerMode m, bool persist = true); + void setFinishedMode(FinishedMode m, bool persist = true); + + // The parse/validate/format statics that once sat here are gone: deleted or + // relocated into the descriptor-table free functions ( // TimerSettings.h; see docs/timer.md) — those are the only spellings. + + // The one shared icon setter: validate/reject/equality-skip/assign/persist/publish + // for one state's slot. The four named setters below are thin delegators over it. + void setIcon(TimerState s, const String &name, bool publish = true); + + void setIconIdle (const String &name, bool publish = true) { setIcon(TimerState::Idle, name, publish); } + void setIconRunning (const String &name, bool publish = true) { setIcon(TimerState::Running, name, publish); } + void setIconPaused (const String &name, bool publish = true) { setIcon(TimerState::Paused, name, publish); } + void setIconFinished(const String &name, bool publish = true) { setIcon(TimerState::Finished, name, publish); } + + void publishIcons(); + + // Republish the current run-state string through the wire seam. + // Public because MQTTManager re-emits it on connect / discovery enable; it + // is the only path that puts the state key on the wire. + void publishState(); + + // Same for the remaining-seconds key: the only path that puts + // remaining on the wire; the periodic republish throttle stays in tick(). + void publishRemaining(); + + // Same for the duration key: the only path that puts the + // trimmed-HMS duration on the wire. Public for the same connect / + // discovery-enable republish sites. + void publishDuration(); + + // Same for the two enum keys, dispatching through their member- + // config rows' publish hooks: the only paths that put buzzer/finished on + // the wire. Public for the same connect / discovery-enable republish sites. + void publishBuzzerMode(); + void publishFinishedMode(); + + // Full wire refresh: republish every Timer wire artifact once — + // the run-state trio plus every member-config row's publish hook, derived + // from TIMER_MEMBER_CONFIG_DESCS so a new published row cannot be skipped. + // The single call the connect / discovery-enable republish sites make. + void publishAllWire(); + + // Publish one HA carrier's read-only JSON attribute object — the carrier's + // mapped settings keys built via timerBuildAttributeGroup — onto its + // json_attr_t topic via the wire seam. Retained, so HA repopulates + // after a restart for free. No-op for a carrier with no mapped keys. + void publishAttributeGroup(TimerHaEntity carrier); + + // Publish every distinct carrier's attribute object once. The single call the + // discovery-enable / reconnect paths make right after publishAllWire(), so HA + // never sees an entity with missing attributes. + void publishAllAttributeGroups(); + + // Clear (empty retained payload) every distinct carrier's json_attr_t topic — + // the teardown mirror of publishAllAttributeGroups(). The discovery teardown + // (TimerHaHost::remove, SHOW_TIMER true->false) calls this so disabling the + // Timer leaves no orphaned attribute object retained on the broker. + void clearAllAttributeGroups(); + + TimerCmdResult parseCommand(const char *json); + + // -- Home Assistant control adapter -- + // Route a single HA timer callback through parseCommand instead of a deep + // setter, so HA edits get the SAME atomic-reject validation, the same + // propagation, and the same codec strings as the {prefix}/timer MQTT surface. + // Each entity builds the minimal JSON command it represents and hands it to + // parseCommand, mirroring the sync receive path. Display-free (no ArduinoHA, + // no MQTT client), so the HA->parseCommand path has no device dependency. + // + // rawValue per entity: + // * Buzzer / Finished : the selected select-option INDEX as a decimal + // string; mapped through the per-enum codec to the canonical + // wire spelling, so emitted and accepted JSON cannot drift. + // * Duration : the raw HH:MM:SS text; parseCommand owns the + // parse/validate (timerParseHMS + range), so that logic is NOT duplicated here. + // * Start/Pause/Reset : ignored; the entity selects the action. + // Returns parseCommand's result so the caller can echo the canonical live + // value back on a non-Ok result (snap-back to the last valid value). + TimerCmdResult timerHaApply(TimerHaEntity entity, const String &rawValue); + + // -- Propagation surface -- + // Run-state and config travel on separate packets; broadcastRunState is private + // (the runStateAction seam owns the verb+mirror pairing). + void broadcastConfig(); + // Validate, gate (echo/follow/target/dedup), then apply an inbound sync packet + // through parseCommand under the _remoteApply guard. A presence beacon + // (presence:true) is harvested into the peer registry UNGATED and short-circuits + // before any command path (it carries no action/config and changes no state). + void applySyncCommand(const char *json); + + // Peer presence. Called from the device loop with the current + // millis(): emits a presence beacon at most once per kPresenceIntervalMs when on + // a real network (NEVER in AP mode — broadcasts unconditionally otherwise so a + // standalone clock is still discoverable), and ages out stale peers each call. + void tickPresence(unsigned long nowMs); + // Peer registry observers (consumed by the dynamic HA Targets select). + // Thin forwarders onto the extracted PeerRegistry, so consumers (MQTTManager) + // are unchanged by the extraction. + int peerCount() const { return _registry.count(); } + bool hasPeer(const String &id) const { return _registry.has(id); } + size_t peerIds(String *out, size_t cap) const { return _registry.ids(out, cap); } + + void onShowTimerChange(bool prev, bool now); + + TimerState getState() const { return state; } + uint32_t getRemaining() const { return remainingSec; } + uint32_t getDuration() const { return durationSec; } + uint32_t getRunDuration() const { return runDurationSec; } + BuzzerMode getBuzzerMode() const { return buzzerMode; } + FinishedMode getFinishedMode() const { return finishedMode; } + + // The raw per-state slot (no Idle fallback — that lives in getIconForState). + // The one indexed read the snapshot/emit hook uses; the four named getters + // delegate here, mirroring how setIcon unified the setters. + const String &getIcon(TimerState s) const { return iconByState[(size_t)s]; } + const String &getIconIdle() const { return getIcon(TimerState::Idle); } + const String &getIconRunning() const { return getIcon(TimerState::Running); } + const String &getIconPaused() const { return getIcon(TimerState::Paused); } + const String &getIconFinished() const { return getIcon(TimerState::Finished); } + const String &getIconForState(TimerState s) const; + + const char *getStateString() const; + + // Canonical output spellings for the timer enums, co-located with + // getStateString() so the one true spelling of each enum lives in one place. + // (The command parser additionally tolerates non-hyphen aliases on input.) + // Public so the member-config table's emit hooks (TimerSettings.cpp) can read + // them when building the propagated config snapshot. + const char *buzzerModeString() const; + const char *finishedModeString() const; + + // Live read-only snapshot for the GET /api/timer observation surface. + // Reports computeCurrentRemaining() (wall-clock fresh), not the throttled + // cached value. See docs/api.md. + String getStateJson() const; +}; + +extern TimerManager_ &TimerManager; + +#endif diff --git a/src/TimerMenu.cpp b/src/TimerMenu.cpp new file mode 100644 index 00000000..f5d63c50 --- /dev/null +++ b/src/TimerMenu.cpp @@ -0,0 +1,127 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerMenu.h" + +#include "TimerManager.h" +#include "TimerSettings.h" + +namespace +{ + // Enum hooks. The setters defer the NVS write (persist=false): the TIMER menu + // applies + publishes live during scroll and persists on the long-press + // commit, where MenuManager opens the PersistBatch guard. Non-capturing + // lambdas decay to the row's function pointers. + uint8_t getBuzzer() { return (uint8_t)TimerManager.getBuzzerMode(); } + void setBuzzer(uint8_t v) { TimerManager.setBuzzerMode((BuzzerMode)v, /*persist=*/false); } + uint8_t getFinished() { return (uint8_t)TimerManager.getFinishedMode(); } + void setFinished(uint8_t v) { TimerManager.setFinishedMode((FinishedMode)v, /*persist=*/false); } +} + +// idx order is the on-screen list order (left/right walks it, wrapping). DURATION +// is first (the HH:MM:SS wheel, delegating to TimerConfigEditor); MAIN is the lone +// Navigation row and sits last (the device reads it as the back-to-main item). +// kind, name, cmdKey, step, codec, labelCount, getEnum, setEnum +// The two EnumCycle slots read their bare leaf value from the per-enum codec +// table's menu column -- no private label copy to drift from it. +const TimerMenuSlot TIMER_MENU_SLOTS[] = { + {TimerMenuKind::Duration, "DURATION", nullptr, 0, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::EnumCycle, "BUZZER", nullptr, 0, TIMER_BUZZER_CODEC, (uint8_t)BuzzerMode::COUNT, getBuzzer, setBuzzer}, + {TimerMenuKind::SteppedRange, "COUNTDOWN", "countdown_seconds", 1, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::EnumCycle, "FINISH", nullptr, 0, TIMER_FINISHED_CODEC, (uint8_t)FinishedMode::COUNT, getFinished, setFinished}, + {TimerMenuKind::SteppedRange, "CLEAR DELAY", "finished_hold", 5, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::SteppedRange, "RE-ALERT INTERVAL", "realert_interval", 5, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::BoolToggle, "ICON", "icon_enabled", 0, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::BoolToggle, "PROGRESS BAR", "bar_enabled", 0, nullptr, 0, nullptr, nullptr}, + {TimerMenuKind::Navigation, "MAIN", nullptr, 0, nullptr, 0, nullptr, nullptr}, +}; + +const size_t TIMER_MENU_SLOT_COUNT = sizeof(TIMER_MENU_SLOTS) / sizeof(TIMER_MENU_SLOTS[0]); + +String timerMenuName(uint8_t slot) +{ + if (slot >= TIMER_MENU_SLOT_COUNT) return String(); + const char *n = TIMER_MENU_SLOTS[slot].name; + return n ? String(n) : String(); +} + +String timerMenuValue(uint8_t slot) +{ + if (slot >= TIMER_MENU_SLOT_COUNT) return String(); + const TimerMenuSlot &s = TIMER_MENU_SLOTS[slot]; + switch (s.kind) + { + case TimerMenuKind::Duration: + { + // The committed duration as a zero-padded HH:MM:SS clock (matches the + // wheel), independent of any live edit in the leaf engine. + return timerClock(TimerManager.getDuration(), ClockStyle::Padded); + } + case TimerMenuKind::EnumCycle: + return String(s.codec[s.getEnum()].menu); + case TimerMenuKind::SteppedRange: + { + const TimerSettingDesc *d = timerSettingByCmdKey(s.cmdKey); + uint16_t v = d ? *static_cast(d->storage) : 0; + return String(v); + } + case TimerMenuKind::BoolToggle: + { + const TimerSettingDesc *d = timerSettingByCmdKey(s.cmdKey); + bool on = d && *static_cast(d->storage); + return String(on ? "ON" : "OFF"); + } + case TimerMenuKind::Navigation: + return String(); + } + return String(); +} + +void timerMenuAdjust(uint8_t slot, int dir) +{ + if (slot >= TIMER_MENU_SLOT_COUNT) return; + const TimerMenuSlot &s = TIMER_MENU_SLOTS[slot]; + switch (s.kind) + { + case TimerMenuKind::Duration: + break; // the duration wheel is driven by the edit engine, not adjust + case TimerMenuKind::EnumCycle: + { + uint8_t cur = s.getEnum(); + uint8_t next = (dir > 0) + ? (uint8_t)((cur + 1) % s.labelCount) + : (uint8_t)((cur + s.labelCount - 1) % s.labelCount); + s.setEnum(next); + break; + } + case TimerMenuKind::SteppedRange: + { + const TimerSettingDesc *d = timerSettingByCmdKey(s.cmdKey); + if (!d) break; + uint16_t &v = *static_cast(d->storage); + if (dir > 0) v = (v + s.step <= d->hi) ? (uint16_t)(v + s.step) : (uint16_t)d->hi; + else v = (v >= d->lo + s.step) ? (uint16_t)(v - s.step) : (uint16_t)d->lo; + break; + } + case TimerMenuKind::BoolToggle: + { + const TimerSettingDesc *d = timerSettingByCmdKey(s.cmdKey); + if (!d) break; + bool &b = *static_cast(d->storage); + b = !b; + break; + } + case TimerMenuKind::Navigation: + break; // no value to adjust + } +} + +TimerNavLeaf timerMenuLeafKind(uint8_t slot, TimerState st) +{ + if (slot >= TIMER_MENU_SLOT_COUNT) return TimerNavLeaf::Value; + if (TIMER_MENU_SLOTS[slot].kind != TimerMenuKind::Duration) + return TimerNavLeaf::Value; + // Duration: editable HH:MM:SS wheel only while Idle; read-only otherwise. + return (st == TimerState::Idle) + ? TimerNavLeaf::DurationEditable + : TimerNavLeaf::DurationReadOnly; +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerMenu.h b/src/TimerMenu.h new file mode 100644 index 00000000..2866ae70 --- /dev/null +++ b/src/TimerMenu.h @@ -0,0 +1,68 @@ +#ifndef TimerMenu_h +#define TimerMenu_h + +#include + +#include "TimerEnums.h" +#include "TimerMenuNav.h" // TimerNavLeaf (the leaf-kind the device hands the nav SM) + +// TIMER menu slot table: the data model behind the on-device TIMER global menu's +// drill-in list (the third member of the Timer descriptor-table family, alongside +// TIMER_SETTINGS_DESCS and TIMER_HA_DESCRIPTORS). One row per list item; the +// TimerMenuNav state machine walks it and MenuManager keeps only the drawing + +// commit. This header is display-free (no DisplayManager) so the name/value/adjust +// logic is testable in isolation. +// +// Two slot families, mirroring the B1 boundary: +// * table-backed slots (SteppedRange / BoolToggle) reuse their TIMER_SETTINGS_DESCS +// row by cmdKey for storage + range -- they cannot drift from the settings table. +// * the two enum slots (buzzer / finished) are member-backed: they carry bespoke +// getEnum/setEnum hooks (like the settings table's `bespoke` fn pointers). Their +// setEnum defers the NVS write to the menu commit (setBuzzerMode(m, persist=false)). + +// Slot kinds. The value kinds (EnumCycle / SteppedRange / BoolToggle) drill into a +// leaf editor; Duration drills into the HH:MM:SS wheel (delegating to the existing +// TimerConfigEditor edit engine, no settings-storage row); Navigation is the lone +// non-value row (MAIN) that walks the device back to the main menu. +enum class TimerMenuKind : uint8_t { Duration, EnumCycle, SteppedRange, BoolToggle, Navigation }; + +struct TimerMenuSlot +{ + TimerMenuKind kind; + const char *name; // list label (the item name): "BUZZER", "COUNTDOWN", "MAIN" + const char *cmdKey; // SteppedRange/BoolToggle: -> TIMER_SETTINGS_DESCS (storage + lo/hi) + uint16_t step; // SteppedRange step + const TimerEnumCodec *codec; // EnumCycle: codec table; .menu column is the bare leaf value + uint8_t labelCount; // EnumCycle modulus + uint8_t (*getEnum)(); // EnumCycle only + void (*setEnum)(uint8_t); // EnumCycle only (routes via TimerManager setter) +}; + +extern const TimerMenuSlot TIMER_MENU_SLOTS[]; +extern const size_t TIMER_MENU_SLOT_COUNT; + +// The slot's LIST LABEL (the item name shown while walking the list), e.g. +// "BUZZER", "COUNTDOWN", "MAIN". Returns "" for an out-of-range slot index. +String timerMenuName(uint8_t slot); + +// The slot's BARE LEAF VALUE (shown while editing the leaf), e.g. "END", "10", +// "ON". The item name already gives the context, so no prefix. The Duration row +// returns the current duration as a zero-padded "HH:MM:SS" clock string; +// Navigation rows (MAIN) have no value and return "". Returns "" for an +// out-of-range index. +String timerMenuValue(uint8_t slot); + +// How the leaf for `slot` behaves, given the timer's current state `st`. The +// Duration row is an editable HH:MM:SS wheel only when the timer is Idle; while +// Running/Paused it is read-only. Every other row is a +// plain Value leaf. The device hands the result to TimerMenuNav, so the Idle-only +// gating decision lives in display-free code. +TimerNavLeaf timerMenuLeafKind(uint8_t slot, TimerState st); + +// Adjust the slot by one step in the given direction (dir > 0 = right/increment, +// dir <= 0 = left/decrement). Stepped ranges saturate at the descriptor's lo/hi; +// enums wrap; bools toggle (either direction). No-op for a Navigation row or an +// out-of-range index. +void timerMenuAdjust(uint8_t slot, int dir); + +#endif diff --git a/src/TimerMenuNav.cpp b/src/TimerMenuNav.cpp new file mode 100644 index 00000000..b4a92913 --- /dev/null +++ b/src/TimerMenuNav.cpp @@ -0,0 +1,101 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerMenuNav.h" + +// Display-free TIMER-menu navigation state machine. +// See the header for the input->outcome contract. The implementation is pure logic +// over {focus, index, origin, leaf, bounds} -- no device, no display, no storage. + +TimerMenuNav::TimerMenuNav() + : _itemCount(1), _mainIndex(0), _index(0), + _focus(TimerNavFocus::List), _origin(TimerNavOrigin::Menu), + _leaf(TimerNavLeaf::Value) {} + +TimerMenuNav::TimerMenuNav(uint8_t itemCount, uint8_t mainIndex, TimerNavOrigin origin) + : _itemCount(itemCount ? itemCount : 1), + _mainIndex(mainIndex), + _index(0), + _focus(TimerNavFocus::List), + _origin(origin), + _leaf(TimerNavLeaf::Value) {} + +void TimerMenuNav::enter(uint8_t itemCount, uint8_t mainIndex, TimerNavOrigin origin) +{ + _itemCount = itemCount ? itemCount : 1; + _mainIndex = mainIndex; + _index = 0; + _focus = TimerNavFocus::List; + _origin = origin; + _leaf = TimerNavLeaf::Value; +} + +TimerNavOutcome TimerMenuNav::navigate(int dir) +{ + if (_focus == TimerNavFocus::Editing) + { + // The leaf value lives in the data model / edit engine; the caller applies + // the step. DURATION steps the active field; a read-only leaf ignores it. + switch (_leaf) + { + case TimerNavLeaf::Value: return TimerNavOutcome::AdjustValue; + case TimerNavLeaf::DurationEditable: return TimerNavOutcome::AdjustField; + case TimerNavLeaf::DurationReadOnly: return TimerNavOutcome::None; + } + return TimerNavOutcome::None; + } + + // List focus: move the cursor with wrap. + if (dir > 0) + _index = (uint8_t)((_index + 1) % _itemCount); + else + _index = (uint8_t)((_index == 0) ? _itemCount - 1 : _index - 1); + return TimerNavOutcome::None; +} + +TimerNavOutcome TimerMenuNav::select(TimerNavLeaf leafKind) +{ + if (_focus == TimerNavFocus::Editing) + { + switch (_leaf) + { + case TimerNavLeaf::Value: + // Short press in a value leaf: confirm and step back up to the list. + _focus = TimerNavFocus::List; + return TimerNavOutcome::ConfirmBackToList; + case TimerNavLeaf::DurationEditable: + // Short press in the duration wheel: cycle H -> M -> S, stay editing. + return TimerNavOutcome::CycleField; + case TimerNavLeaf::DurationReadOnly: + // Read-only: any press returns to the list. + _focus = TimerNavFocus::List; + return TimerNavOutcome::BackToList; + } + } + + // List focus. + if (onMain()) + return TimerNavOutcome::GoToMainMenu; // commit handled by the device + + _leaf = leafKind; // remember how the leaf behaves + _focus = TimerNavFocus::Editing; // drill into the leaf + return TimerNavOutcome::EnterLeaf; +} + +TimerNavOutcome TimerMenuNav::back() +{ + if (_focus == TimerNavFocus::Editing) + { + TimerNavLeaf leaf = _leaf; + _focus = TimerNavFocus::List; // every leaf back-out returns to the list + // The duration leaf commits its run-state value on back-out; value leaves + // are already live in RAM (committed with the list -> main batch). + return (leaf == TimerNavLeaf::DurationEditable) + ? TimerNavOutcome::CommitDuration + : TimerNavOutcome::BackToList; + } + + // List focus: context-aware exit by entry origin. + return (_origin == TimerNavOrigin::App) + ? TimerNavOutcome::ExitMenu + : TimerNavOutcome::GoToMainMenu; +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerMenuNav.h b/src/TimerMenuNav.h new file mode 100644 index 00000000..90fb0b23 --- /dev/null +++ b/src/TimerMenuNav.h @@ -0,0 +1,93 @@ +#ifndef TimerMenuNav_h +#define TimerMenuNav_h + +#include + +// Display-free navigation state machine for the TIMER global menu. +// It owns the whole interaction model behind a small interface: +// list/leaf focus, the selected index, and the entry origin. Button inputs map +// to OUTCOMES that the device layer (MenuManager) acts on; the device keeps only +// drawing (the list indicator / the leaf value) and the single commit. Because +// it depends on nothing but Arduino.h, the interaction model is testable +// without a device. +// +// The list is the TIMER menu's slot rows. Value rows (enum / number / bool) drill +// into a leaf editor; the lone navigation row (MAIN) returns to the main menu. +// MAIN's index is supplied by the device from the slot table, so the state +// machine never hard-codes which row is MAIN. + +enum class TimerNavFocus : uint8_t { List, Editing }; +enum class TimerNavOrigin : uint8_t { Menu, App }; + +// The kind of leaf a value row drills into. Most rows are a plain Value leaf +// (cycle/step/toggle). DURATION is a field editor (HH/MM/SS wheel) when the timer +// is Idle, and read-only otherwise -- the device maps slot+timer-state to this via +// timerMenuLeafKind(), so the gating decision lives in display-free code. +enum class TimerNavLeaf : uint8_t { Value, DurationEditable, DurationReadOnly }; + +// Outcomes the device layer acts on. Anything not listed (plain list movement) is +// reported as None; the caller reads focus()/index() for the new cursor position. +enum class TimerNavOutcome : uint8_t +{ + None, // handled internally (list cursor moved / read-only no-op) + AdjustValue, // Editing, value leaf: caller applies timerMenuAdjust(index(), dir) + AdjustField, // Editing, DURATION leaf: caller steps the active H/M/S field + CycleField, // Editing, DURATION leaf, short press: cycle the H/M/S field + EnterLeaf, // List focus, value/duration row selected: drilled into its leaf + ConfirmBackToList, // Editing, value leaf, short press: confirm, back to the list + BackToList, // Editing, long press (or read-only press): back to the list + CommitDuration, // Editing, DURATION leaf, long press: commit duration, back to list + GoToMainMenu, // commit + return to the main menu + ExitMenu, // commit + return to the Timer app (origin == App) +}; + +class TimerMenuNav +{ +public: + // Default: an empty single-item list in List focus. Use enter() before driving. + TimerMenuNav(); + // itemCount = number of list rows; mainIndex = the MAIN (navigation) row. + TimerMenuNav(uint8_t itemCount, uint8_t mainIndex, + TimerNavOrigin origin = TimerNavOrigin::Menu); + + // (Re)enter the list: reset to List focus, index 0, with fresh bounds + origin. + // Called by the device whenever the TIMER menu is opened. + void enter(uint8_t itemCount, uint8_t mainIndex, TimerNavOrigin origin); + + TimerNavFocus focus() const { return _focus; } + uint8_t index() const { return _index; } + TimerNavOrigin origin() const { return _origin; } + bool onMain() const { return _index == _mainIndex; } + + // left/right. List focus: move the cursor with wrap (-> None). Editing focus: + // value leaf -> AdjustValue; DurationEditable -> AdjustField; DurationReadOnly + // -> None (no-op). + TimerNavOutcome navigate(int dir); + + // short press (middle button). + // List focus, value/duration row -> EnterLeaf (focus becomes Editing); pass + // the target row's leaf kind so the leaf behaves correctly once editing. + // List focus, MAIN -> GoToMainMenu. + // Editing, value leaf -> ConfirmBackToList (focus becomes List). + // Editing, DurationEditable leaf -> CycleField (stays Editing). + // Editing, DurationReadOnly leaf -> BackToList (any press returns to the list). + TimerNavOutcome select(TimerNavLeaf leafKind = TimerNavLeaf::Value); + + // long press (middle button, held). + // List focus -> context-aware exit: GoToMainMenu (origin Menu) or + // ExitMenu (origin App). + // Editing, value leaf -> BackToList (value already live in RAM). + // Editing, DurationEditable leaf -> CommitDuration (then back to the list). + // Editing, DurationReadOnly leaf -> BackToList. + TimerNavOutcome back(); + +private: + uint8_t _itemCount; + uint8_t _mainIndex; + uint8_t _index; + TimerNavFocus _focus; + TimerNavOrigin _origin; + TimerNavLeaf _leaf; // kind of the leaf currently being edited (Editing focus) +}; + +#endif diff --git a/src/TimerRuntime.cpp b/src/TimerRuntime.cpp new file mode 100644 index 00000000..4ea22e59 --- /dev/null +++ b/src/TimerRuntime.cpp @@ -0,0 +1,183 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerRuntime.h" + +namespace TimerRuntime +{ + // The Finished transition's effect bundle. Ported straight from the old + // TimerManager::enterFinished, in the SAME order the imperative code emitted: + // publishState, publishRemaining, then the three gated effects (switch-to-app, + // brightness restore, end tone). Keeping the order byte-identical is what lets + // the adapter stay behaviourally indistinguishable on device. + static void appendFinished(std::vector &fx, const Inputs &in) + { + fx.push_back({EffectKind::PublishState}); + fx.push_back({EffectKind::PublishRemaining}); + if (in.navigationFree) + fx.push_back({EffectKind::SwitchToTimerApp}); + if (in.matrixOff) + { + Effect e{EffectKind::SetBrightness}; + e.brightness = in.brightness; + fx.push_back(e); + } + if (in.playEndTone) + { + Effect e{EffectKind::PlayTone}; + e.tone = Tone::End; + fx.push_back(e); + } + } + + // The reset bundle, shared by an explicit reset and a paused-duration edit: + // stop any sound, republish the (now Idle) run-state, and — when the panel is + // dark — drop brightness to 0. Ported in order from the old TimerManager::reset. + static void appendReset(std::vector &fx, const Inputs &in) + { + fx.push_back({EffectKind::StopSound}); + fx.push_back({EffectKind::PublishState}); + fx.push_back({EffectKind::PublishRemaining}); + if (in.matrixOff) + fx.push_back({EffectKind::SetBrightness}); // brightness 0 + } + + // The input-driven lifecycle transitions: start/pause/reset/ + // setDuration decided as pure phase transitions over the same effect seam. The + // adapter owns the run-state bookkeeping each Transition names (enterRunning's + // runStart* capture, the one-shot override restore behind ToIdle); here we only + // decide the phase change and the ordered effects. + static Result stepCommand(const State &s, const Inputs &in) + { + Result r; + switch (in.command) + { + case Command::Start: + if (s.phase == TimerState::Running) break; // already running: no-op + if (s.phase == TimerState::Finished) + r.effects.push_back({EffectKind::StopSound}); + r.effects.push_back({EffectKind::PublishState}); + r.effects.push_back({EffectKind::PublishRemaining}); + r.transition = Transition::ToRunning; + break; + + case Command::Pause: + if (s.phase == TimerState::Running) + { + r.effects.push_back({EffectKind::PublishState}); + r.effects.push_back({EffectKind::PublishRemaining}); + r.transition = Transition::ToPaused; + } + else if (s.phase == TimerState::Paused) + { + // A second press resumes: same publishes as enterRunning. + r.effects.push_back({EffectKind::PublishState}); + r.effects.push_back({EffectKind::PublishRemaining}); + r.transition = Transition::ToRunning; + } + break; + + case Command::Reset: + appendReset(r.effects, in); + r.transition = Transition::ToIdle; + break; + + case Command::SetDuration: + if (s.phase == TimerState::Idle) + { + r.effects.push_back({EffectKind::PublishRemaining}); + r.transition = Transition::IdleReload; + } + else if (s.phase == TimerState::Paused) + { + // US6: editing the duration while Paused resets cleanly to Idle + // with the new duration — the reset bundle, then remaining reloads. + appendReset(r.effects, in); + r.transition = Transition::ToIdle; + } + // Running / Finished: duration changes silently (the adapter still + // persists + republishes it); no run-state transition. + break; + + case Command::Tick: + break; // unreachable: step() dispatches Tick to the countdown path + } + return r; + } + + Result step(const State &state, unsigned long nowMs, const Inputs &in) + { + if (in.command != Command::Tick) + return stepCommand(state, in); + + Result r; + + if (state.phase == TimerState::Finished) + { + if (in.finishedMode == FinishedMode::AutoClear && + (nowMs - state.enteredFinishedMs >= (unsigned long)in.finishedHold * 1000UL)) + { + // Auto-clear: stop the sound, return to Idle, republish, and (when + // the panel is dark) drop brightness to 0. The adapter maps ToIdle + // to returnToIdle()+state/remaining so the override store stays there. + r.effects.push_back({EffectKind::StopSound}); + r.effects.push_back({EffectKind::PublishState}); + r.effects.push_back({EffectKind::PublishRemaining}); + if (in.matrixOff) + r.effects.push_back({EffectKind::SetBrightness}); // brightness 0 + r.transition = Transition::ToIdle; + } + else if (in.finishedMode == FinishedMode::ReAlert && in.realertArmed && + (nowMs - state.lastRealertMs >= (unsigned long)in.realertInterval * 1000UL)) + { + // Re-sound the end tone at the interval. The anchor advances even + // when a tone is already playing (one tone at a time), so the next + // re-alert lands a full interval later — not immediately after. + if (!in.isPlaying && in.realertToneSet) + { + Effect e{EffectKind::PlayTone}; + e.tone = Tone::End; + r.effects.push_back(e); + } + r.realertFired = true; + } + return r; + } + + if (state.phase != TimerState::Running) + return r; + + if (in.newRemaining != state.remainingSec) + { + if (in.countdownArmed) + { + // Beep if any second in [1, countdownSeconds] was crossed this + // tick. The buzzer plays one tone at a time, so a single beep + // covers the gap when tick() falls behind (rather than queuing N + // back-to-back plays). + uint32_t lo = in.newRemaining > 0 ? in.newRemaining : 1; + uint32_t hi = state.remainingSec > 0 ? state.remainingSec - 1 : 0; + if (hi > in.countdownSeconds) hi = in.countdownSeconds; + if (hi >= lo && !in.isPlaying) + { + Effect e{EffectKind::PlayTone}; + e.tone = Tone::Tick; + r.effects.push_back(e); + } + } + + if (in.publishInterval > 0 && + (nowMs - state.lastPublishMs >= (unsigned long)in.publishInterval * 1000UL)) + { + r.effects.push_back({EffectKind::PublishRemaining}); + r.publishFired = true; + } + } + + if (in.newRemaining == 0) + { + appendFinished(r.effects, in); + r.transition = Transition::ToFinished; + } + return r; + } +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerRuntime.h b/src/TimerRuntime.h new file mode 100644 index 00000000..2542265f --- /dev/null +++ b/src/TimerRuntime.h @@ -0,0 +1,143 @@ +#ifndef TimerRuntime_h +#define TimerRuntime_h + +#include +#include + +#include "TimerEnums.h" // TimerState / FinishedMode (the run-state the engine keys on) + +// Pure timer run-state engine. step() is a total +// function of (state, nowMs, inputs) that RETURNS the ordered effects plus the +// run-state bookkeeping the adapter must apply, instead of performing anything; +// the TimerManager adapter executes each returned effect in order against +// PeripheryManager / DisplayManager / the wire seam and applies the bookkeeping. +// +// No hardware headers: the engine names PeripheryManager/DisplayManager nowhere, +// so it compiles on its own and links no TimerManager singleton — that +// dependency-free link is the architectural claim, exactly as it is for +// SyncEnvelope and TimerCommand. +namespace TimerRuntime +{ + // Which pre-loaded tune the adapter should play. The engine stays free of the + // RTTTL strings themselves: End -> endRtttl, Tick -> tickRtttl at the adapter. + enum class Tone : uint8_t { End, Tick }; + + // What the adapter is asking the engine to decide. Tick is the wall-clock + // countdown step; the rest are the input-driven lifecycle + // commands — the same public verbs TimerManager exposes. Default + // is Tick so an Inputs built without naming a command is the countdown step. + enum class Command : uint8_t { Tick, Start, Pause, Reset, SetDuration }; + + // The full effect vocabulary. The Running tick emits PlayTone(Tick)/publish; + // the Finished transition emits the switch/brightness/end-tone bundle; + // auto-clear emits StopSound + the return-to-Idle publish/brightness effects. + enum class EffectKind : uint8_t + { + PlayTone, + StopSound, + SwitchToTimerApp, + SetBrightness, + PublishState, + PublishRemaining, + }; + + // Flat tagged effect. `tone` is meaningful only for PlayTone; `brightness` + // only for SetBrightness. A flat struct (not a union/variant) keeps the type + // trivially copyable and gnu++11-friendly for the device build. + struct Effect + { + // The (non-explicit) kind constructor keeps the engine's one-value + // push_back({EffectKind::X}) brace-inits valid in C++11 (a class with + // default member initializers is not a C++11 aggregate). + Effect() = default; + Effect(EffectKind kind_) : kind(kind_) {} + + EffectKind kind = EffectKind::PublishState; + Tone tone = Tone::End; // valid iff kind == PlayTone + uint8_t brightness = 0; // valid iff kind == SetBrightness + }; + + // The run-state bookkeeping the adapter applies AFTER executing the effects. + // The engine decides the transition; the adapter owns the state mutation (and + // the override-store restore behind ToIdle) so the override store stays in the + // manager. + enum class Transition : uint8_t + { + None, // no phase change + ToFinished, // Running reached zero + ToIdle, // return to Idle: Finished auto-clear, reset, or a paused-duration edit + // (adapter: returnToIdle() + remaining = durationSec) + ToRunning, // start (fresh: load durationSec) or resume from Paused (keep remaining) + ToPaused, // Running -> Paused (adapter freezes remaining = newRemaining) + IdleReload, // duration edited while Idle: remaining = durationSec, phase stays Idle + }; + + // Environment gates resolved to plain bools/values so the engine needs none of + // the globals/singletons that own them: + // navigationFree = !GAME_ACTIVE && !BLOCK_NAVIGATION && !MenuManager.inMenu + // matrixOff = MATRIX_OFF (restore brightness when the panel is dark) + // playEndTone = SOUND_ACTIVE && buzzer != Off && endRtttl set + // countdownArmed = SOUND_ACTIVE && buzzer == Countdown && tickRtttl set + // realertArmed = SOUND_ACTIVE && buzzer != Off + // realertToneSet = endRtttl set + struct Inputs + { + Command command = Command::Tick; // which lifecycle decision to make + + // Running tick. + uint32_t newRemaining = 0; // computeCurrentRemaining() + bool countdownArmed = false; + bool isPlaying = false; // PeripheryManager.isPlaying() + uint16_t countdownSeconds = 0; // TIMER_COUNTDOWN_SECONDS + uint16_t publishInterval = 0; // TIMER_PUBLISH_INTERVAL (sec; 0 disables) + + // Lifecycle commands (start/pause/reset/setDuration). durationSec is the + // configured full duration; the adapter loads it into remaining on a fresh + // start / an Idle duration edit, and into remaining on the return-to-Idle. + uint32_t durationSec = 0; + + // Running->Finished transition. + bool navigationFree = false; + bool matrixOff = false; + uint8_t brightness = 0; + bool playEndTone = false; + + // Finished branch. + FinishedMode finishedMode = FinishedMode::AutoClear; + uint16_t finishedHold = 0; // TIMER_FINISHED_HOLD (sec) + uint16_t realertInterval = 0; // TIMER_REALERT_INTERVAL (sec) + bool realertArmed = false; + bool realertToneSet = false; + }; + + struct State + { + // The explicit constructor keeps the adapter's five-value brace-init valid in + // C++11 (a class with default member initializers is not a C++11 aggregate). + State() = default; + State(TimerState phase_, uint32_t remainingSec_, unsigned long lastPublishMs_, + unsigned long enteredFinishedMs_, unsigned long lastRealertMs_) + : phase(phase_), remainingSec(remainingSec_), lastPublishMs(lastPublishMs_), + enteredFinishedMs(enteredFinishedMs_), lastRealertMs(lastRealertMs_) {} + + TimerState phase = TimerState::Idle; + uint32_t remainingSec = 0; // the previous remaining (before this tick) + unsigned long lastPublishMs = 0; // Running publish throttle anchor + unsigned long enteredFinishedMs = 0; // auto-clear hold anchor + unsigned long lastRealertMs = 0; // re-alert interval anchor + }; + + struct Result + { + std::vector effects; + Transition transition = Transition::None; + bool publishFired = false; // Running: adapter sets lastPublishMs = nowMs + bool realertFired = false; // Finished: adapter sets lastRealertMs = nowMs + }; + + // Returns the ordered effects + bookkeeping for the tick that `state` (with + // `inputs` at `nowMs`) represents. + Result step(const State &state, unsigned long nowMs, const Inputs &in); +} + +#endif diff --git a/src/TimerSettings.cpp b/src/TimerSettings.cpp new file mode 100644 index 00000000..0eacb1a6 --- /dev/null +++ b/src/TimerSettings.cpp @@ -0,0 +1,592 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerSettings.h" + +#include +#include // snprintf (timerFormatHMS, the bar-color formatters) + +// The descriptor-table family is the PURE Timer validation surface: it links +// against ArduinoJson + the enum codec tables ONLY -- no Globals.h (FastLED), no +// TimerManager singleton, no MQTTManager, no Preferences. That dependency-light link +// is what lets TimerCommand::classify be exercised in isolation. The impure +// half -- the member-config apply/emit/publish hooks, the NVS round-trip, and the +// full-config dump, all of which DO touch the singleton / MQTT / Preferences -- +// lives in TimerSettingsApply.cpp. The two share this header. +// +// The 13 persisted timer-setting globals are DEFINED here (moved out of Globals.cpp; +// Globals.h keeps the extern decls so every other caller is source-unchanged) so the +// TIMER_SETTINGS_DESCS storage pointers resolve without dragging in Globals.cpp. +uint32_t TIMER_MAX_DURATION = 86400; +uint16_t TIMER_PUBLISH_INTERVAL = 1; +uint16_t TIMER_FINISHED_HOLD = 10; +uint16_t TIMER_REALERT_INTERVAL = 15; +uint16_t TIMER_COUNTDOWN_SECONDS = 3; +String TIMER_MELODY_TICK = "timer_tick"; +String TIMER_MELODY_END = "timer_end"; +bool TIMER_BAR_ENABLED = true; +bool TIMER_ICON_ENABLED = true; +uint32_t TIMER_BAR_COLOR = 0; +uint32_t TIMER_BAR_BG_COLOR = 0; // 0 = black = no track (literal off) +bool TIMER_SYNC_FOLLOW = true; // fresh clock is a follower; standalone is opt-out +String TIMER_SYNC_TARGETS = ""; + +namespace +{ + // sync_targets accepts "" (off), "all", or a comma list of device-id tokens + // ([A-Za-z0-9_-], 1..32 each). Moved here from TimerManager.cpp so the table is + // the single home of the sync_targets validation. Same rule as before. + bool isValidSyncTargets(const String &s) + { + String t = s; t.trim(); + if (t.length() == 0 || t == "all") return true; + int start = 0; + const int n = t.length(); + while (start <= n) + { + int comma = t.indexOf(',', start); + if (comma < 0) comma = n; + String tok = t.substring(start, comma); tok.trim(); + if (tok.length() == 0 || tok.length() > 32) return false; + for (size_t i = 0; i < tok.length(); ++i) + { + char c = tok[i]; + bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || c == '_' || c == '-'; + if (!ok) return false; + } + if (comma == n) break; + start = comma + 1; + } + return true; + } + + // bar_color: a JSON number 0..0xFFFFFF, or an "#RRGGBB" / "RRGGBB" hex string. + bool parseBarColor(JsonVariantConst v, TcValue &out) + { + if (v.is() || v.is()) + { + uint32_t n = v.as(); + if (n > 0xFFFFFFu) return false; + out.num = n; + return true; + } + if (v.is() || v.is()) + { + String s = v.as(); + s.trim(); + if (s.length() > 0 && s[0] == '#') s = s.substring(1); + if (s.length() != 6) return false; // exactly RRGGBB + for (size_t i = 0; i < s.length(); ++i) + { + char c = s[i]; + bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!ok) return false; + } + out.num = (uint32_t)strtoul(s.c_str(), nullptr, 16); + return true; + } + return false; + } + + // max_duration carrier-native HA-attribute formatter: + // renders the stored cap (raw seconds, a U32) as the trimmed H:MM:SS clock + // string the Duration text entity's OWN state speaks, by reusing the exact + // formatHMS the Duration state uses ("24:00:00", "1:00:00", "0:45"). This is + // the first key whose attribute representation differs PER CARRIER: the state + // sensor keeps the raw-seconds number (no formatter), while the Duration + // carrier renders this clock string — each carrier in its native form, over + // the same persisted storage, so the underlying value cannot drift. + void formatMaxDurationHMS(const TimerSettingDesc &d, JsonDocument &doc) + { + uint32_t v = *static_cast(d.storage); + doc[d.cmdKey] = timerFormatHMS(v); // family-local pure formatter + } + + // sync_targets: strict string type, then the comma-list rule above. + bool parseSyncTargets(JsonVariantConst v, TcValue &out) + { + if (!(v.is() || v.is())) return false; + String s = v.as(); + if (!isValidSyncTargets(s)) return false; + out.str = s; + return true; + } +} + +// bar_color / bar_bg_color HA-attribute formatters. +// External linkage (not file-local) so both the attribute-group table here and the +// HTTP full-config dump in TimerSettingsApply.cpp render the stored 0xRRGGBB int the +// same way -- they cannot disagree about the string form. bar_color's off +// sentinel (0) is "default" (follow text color); bar_bg_color's off is +// "none" (black = no track, literal off); any other value is uppercase "#RRGGBB". +void timerFormatBarColor(const TimerSettingDesc &d, JsonDocument &doc) +{ + uint32_t v = *static_cast(d.storage); + if (v == 0) { doc[d.cmdKey] = "default"; return; } + char buf[8]; + snprintf(buf, sizeof(buf), "#%06X", (unsigned)(v & 0xFFFFFFu)); + doc[d.cmdKey] = buf; +} + +void timerFormatBarBgColor(const TimerSettingDesc &d, JsonDocument &doc) +{ + uint32_t v = *static_cast(d.storage); + if (v == 0) { doc[d.cmdKey] = "none"; return; } + char buf[8]; + snprintf(buf, sizeof(buf), "#%06X", (unsigned)(v & 0xFFFFFFu)); + doc[d.cmdKey] = buf; +} + +// One row per persisted value-config Timer key. The two inSnapshot=false rows are +// the sync roles (local identity), excluded from the propagated config block. +// cmdKey, devKey, nvsKey, type, check, lo, hi, dfltNum, dfltStr, bespoke, inSnapshot, storage +const TimerSettingDesc TIMER_SETTINGS_DESCS[] = { + {"finished_hold", "timer_finished_hold", "TFHOLD", TcType::U16, TcCheck::UIntRange, 1, 300, 10, nullptr, nullptr, true, &TIMER_FINISHED_HOLD}, + {"realert_interval", "timer_realert_interval", "TRALERT", TcType::U16, TcCheck::UIntRange, 5, 300, 15, nullptr, nullptr, true, &TIMER_REALERT_INTERVAL}, + {"countdown_seconds", "timer_countdown_seconds", "TCDOWN", TcType::U16, TcCheck::UIntRange, 0, 30, 3, nullptr, nullptr, true, &TIMER_COUNTDOWN_SECONDS}, + {"max_duration", "timer_max_duration", "TMAXD", TcType::U32, TcCheck::UIntRange, 1, 604800, 86400, nullptr, nullptr, true, &TIMER_MAX_DURATION}, + {"remaining_publish_interval", "timer_remaining_publish_interval", "TPUBI", TcType::U16, TcCheck::UIntRange, 1, 60, 1, nullptr, nullptr, true, &TIMER_PUBLISH_INTERVAL}, + {"icon_enabled", "timer_icon_enabled", "TICONEN", TcType::Bool, TcCheck::Bool, 0, 0, 1, nullptr, nullptr, true, &TIMER_ICON_ENABLED}, + {"bar_enabled", "timer_bar_enabled", "TBAREN", TcType::Bool, TcCheck::Bool, 0, 0, 1, nullptr, nullptr, true, &TIMER_BAR_ENABLED}, + {"bar_color", "timer_bar_color", "TBARC", TcType::U32, TcCheck::Bespoke, 0, 0, 0, nullptr, parseBarColor, true, &TIMER_BAR_COLOR}, + {"bar_bg_color", "timer_bar_bg_color", "TBARBC", TcType::U32, TcCheck::Bespoke, 0, 0, 0, nullptr, parseBarColor, true, &TIMER_BAR_BG_COLOR}, + {"melody_tick", "timer_melody_tick", "TMTICK", TcType::Str, TcCheck::Name, 0, 0, 0, "timer_tick", nullptr, true, &TIMER_MELODY_TICK}, + {"melody_end", "timer_melody_end", "TMEND", TcType::Str, TcCheck::Name, 0, 0, 0, "timer_end", nullptr, true, &TIMER_MELODY_END}, + {"sync_follow", "timer_sync_follow", "TSYNF", TcType::Bool, TcCheck::Bool, 0, 0, 1, nullptr, nullptr, false, &TIMER_SYNC_FOLLOW}, + {"sync_targets", "timer_sync_targets", "TSYNT", TcType::Str, TcCheck::Bespoke, 0, 0, 0, "", parseSyncTargets, false, &TIMER_SYNC_TARGETS}, +}; + +const size_t TIMER_SETTINGS_DESC_COUNT = sizeof(TIMER_SETTINGS_DESCS) / sizeof(TIMER_SETTINGS_DESCS[0]); + +// The header's compile-time extent (used to size the one-shot snapshot buffer) must +// match the actual table. If a row is added, bump TIMER_SETTINGS_DESC_CAP. +static_assert(sizeof(TIMER_SETTINGS_DESCS) / sizeof(TIMER_SETTINGS_DESCS[0]) == TIMER_SETTINGS_DESC_CAP, + "TIMER_SETTINGS_DESC_CAP must equal the descriptor row count"); + +bool timerSettingParse(const TimerSettingDesc &d, JsonVariantConst v, TcValue &out) +{ + if (d.bespoke) return d.bespoke(v, out); + + switch (d.check) + { + case TcCheck::Bool: + if (!v.is()) return false; + out.b = v.as(); + return true; + + case TcCheck::UIntRange: + { + if (!(v.is() || v.is())) return false; // number only (not bool/string/null) + uint32_t n = v.as(); + if (n < d.lo || n > d.hi) return false; + out.num = n; + return true; + } + + case TcCheck::Name: + { + // Bare filename; same char-rule as icons. Empty resets to the default at + // coerce time (matches legacy melody semantics). Mirrors the legacy path, + // which coerced via as() without a strict pre-type-check. + String s = v.as(); + if (!timerIsValidIconName(s)) return false; + out.str = (s.length() == 0) ? String(d.dfltStr) : s; + return true; + } + + case TcCheck::Bespoke: + return false; // unreachable: bespoke rows carry d.bespoke, handled above + } + return false; +} + +bool timerSettingStore(const TimerSettingDesc &d, const TcValue &v) +{ + switch (d.type) + { + case TcType::U16: + { + uint16_t *p = static_cast(d.storage); + if (*p == (uint16_t)v.num) return false; + *p = (uint16_t)v.num; + return true; + } + case TcType::U32: + { + uint32_t *p = static_cast(d.storage); + if (*p == v.num) return false; + *p = v.num; + return true; + } + case TcType::Bool: + { + bool *p = static_cast(d.storage); + if (*p == v.b) return false; + *p = v.b; + return true; + } + case TcType::Str: + { + String *p = static_cast(d.storage); + if (*p == v.str) return false; + *p = v.str; + return true; + } + } + return false; +} + +// timerSettingsLoadNvs / timerSettingsSaveNvs (Preferences round-trip) moved to the +// impure TimerSettingsApply.cpp: they touch Preferences, which this pure TU +// deliberately does not link. + +void timerSettingsLoadDevJson(JsonObjectConst obj) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (!obj.containsKey(d.devKey)) continue; + TcValue val; + if (timerSettingParse(d, obj[d.devKey], val)) // per-key best-effort: skip if invalid + timerSettingStore(d, val); + } +} + +void timerSettingEmitValue(const TimerSettingDesc &d, JsonDocument &doc) +{ + switch (d.type) + { + case TcType::U16: doc[d.cmdKey] = *static_cast(d.storage); break; + case TcType::U32: doc[d.cmdKey] = *static_cast(d.storage); break; + case TcType::Bool: doc[d.cmdKey] = *static_cast (d.storage); break; + case TcType::Str: doc[d.cmdKey] = *static_cast (d.storage); break; + } +} + +void timerSettingsBuildSnapshot(JsonDocument &doc) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (!d.inSnapshot) continue; + timerSettingEmitValue(d, doc); + } +} + +void timerSettingsCaptureSnapshot(TcValue out[]) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (!d.inSnapshot) continue; // sync_* (local identity) excluded by construction + switch (d.type) + { + case TcType::U16: out[i].num = *static_cast(d.storage); break; + case TcType::U32: out[i].num = *static_cast(d.storage); break; + case TcType::Bool: out[i].b = *static_cast (d.storage); break; + case TcType::Str: out[i].str = *static_cast (d.storage); break; + } + } +} + +void timerSettingsRestoreSnapshot(const TcValue in[]) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (!d.inSnapshot) continue; + timerSettingStore(d, in[i]); // dispatch-by-type write; equality-skip return ignored + } +} + +const TimerSettingDesc *timerSettingByCmdKey(const char *cmdKey) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + if (strcmp(TIMER_SETTINGS_DESCS[i].cmdKey, cmdKey) == 0) + return &TIMER_SETTINGS_DESCS[i]; + return nullptr; +} + +// Inline RTTTL classifier/validator. Pure, no globals touched. +namespace +{ + // RTTTL tunes for a single timer alarm/tick are short; cap so a pathological + // payload can't bloat the one-shot run state. + constexpr size_t kInlineRtttlMaxLen = 256; + + // True iff the comma-separated control section carries at least one RTTTL default + // token -- a token whose key is exactly d/o/b (duration/octave/beat). Checks the + // token PREFIX (not a substring), so "foo=4" is not mistaken for an "o=" default. + bool controlHasDefaultToken(const String &control) + { + int start = 0; + const int n = control.length(); + while (start <= n) + { + int comma = control.indexOf(',', start); + if (comma < 0) comma = n; + String tok = control.substring(start, comma); + tok.trim(); + tok.toLowerCase(); + if (tok.startsWith("d=") || tok.startsWith("o=") || tok.startsWith("b=")) return true; + if (comma == n) break; + start = comma + 1; + } + return false; + } +} + +bool timerMelodyIsInline(const String &s) +{ + // A bare melody file-name token is [A-Za-z0-9_-]* and never contains a colon; + // an inline RTTTL tune always carries ':' separators. Content is the only signal. + return s.indexOf(':') >= 0; +} + +bool timerMelodyValidateInline(const String &s) +{ + if (s.length() == 0 || s.length() > kInlineRtttlMaxLen) return false; + + // RTTTL is name:control:notes -- exactly two colons (name may be empty). + int c1 = s.indexOf(':'); + if (c1 < 0) return false; + int c2 = s.indexOf(':', c1 + 1); + if (c2 < 0) return false; + if (s.indexOf(':', c2 + 1) >= 0) return false; // a third colon is malformed + + String control = s.substring(c1 + 1, c2); + String notes = s.substring(c2 + 1); + if (control.length() == 0 || notes.length() == 0) return false; + + // The control section must carry at least one RTTTL default token (d=/o=/b=). + if (!controlHasDefaultToken(control)) return false; + + return true; +} + +// Relocated out of TimerManager. Self-contained: inlines the +// h*3600+m*60+sec sum (timerHmsToSeconds below) rather than delegating, so the +// parse stays one readable pass -- behaviour-identical to the former static. +bool timerParseHMS(const String &in, uint32_t &outSeconds) +{ + String s = in; + s.trim(); + if (s.length() == 0) return false; + + // Split on ':' into up to three numeric fields. Colon count decides units: + // two colons = HH:MM:SS, one = MM:SS, none = bare seconds. Each field must be + // a non-empty run of digits. Fields are summed without a 0-59 cap (carry). + uint32_t fields[3] = {0, 0, 0}; + int count = 0; + int start = 0; + for (int i = 0; i <= s.length(); i++) + { + if (i == s.length() || s[i] == ':') + { + if (count >= 3) return false; // more than two colons + int len = i - start; + if (len == 0) return false; // empty field (e.g. "5:", ":30") + uint32_t v = 0; + for (int j = start; j < i; j++) + { + char c = s[j]; + if (c < '0' || c > '9') return false; // non-numeric + v = v * 10 + (uint32_t)(c - '0'); + } + fields[count++] = v; + start = i + 1; + } + } + + uint32_t h = 0, m = 0, sec = 0; + if (count == 3) { h = fields[0]; m = fields[1]; sec = fields[2]; } + else if (count == 2) { m = fields[0]; sec = fields[1]; } + else { sec = fields[0]; } + + outSeconds = h * 3600UL + m * 60UL + sec; + return true; +} + +// Relocated out of TimerManager. Pure case-insensitive +// membership check over the three action verbs -- behaviour-identical. +bool timerIsValidAction(const String &s) +{ + String a = s; a.toLowerCase(); + return a == "start" || a == "pause" || a == "reset"; +} + +// Relocated out of TimerManager's statics so the command validator links the +// table family, not the singleton. Behaviour-identical to the former statics, all +// of which are gone (the thin forwarders were deleted). + +bool timerIsValidIconName(const String &name) +{ + // Empty is valid (clears the icon). Otherwise [A-Za-z0-9_-], length cap 32. + if (name.length() == 0) return true; + if (name.length() > 32) return false; + for (size_t i = 0; i < name.length(); ++i) + { + char c = name[i]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (!ok) return false; + } + return true; +} + +bool timerParseBuzzerMode(const String &s, BuzzerMode &out) +{ + uint8_t idx; + if (!timerEnumParse(TIMER_BUZZER_CODEC, TIMER_BUZZER_CODEC_COUNT, s, idx)) return false; + out = (BuzzerMode)idx; + return true; +} + +bool timerParseFinishedMode(const String &s, FinishedMode &out) +{ + uint8_t idx; + if (!timerEnumParse(TIMER_FINISHED_CODEC, TIMER_FINISHED_CODEC_COUNT, s, idx)) return false; + out = (FinishedMode)idx; + return true; +} + +String timerClock(uint32_t seconds, ClockStyle style) +{ + uint32_t h = seconds / 3600; + uint32_t m = (seconds % 3600) / 60; + uint32_t s = seconds % 60; + char buf[16]; + switch (style) + { + case ClockStyle::Trimmed: + if (h > 0) snprintf(buf, sizeof(buf), "%u:%02u:%02u", (unsigned)h, (unsigned)m, (unsigned)s); + else snprintf(buf, sizeof(buf), "%u:%02u", (unsigned)m, (unsigned)s); + break; + case ClockStyle::Padded: + snprintf(buf, sizeof(buf), "%02u:%02u:%02u", (unsigned)h, (unsigned)m, (unsigned)s); + break; + case ClockStyle::Compact: + if (seconds < 3600) snprintf(buf, sizeof(buf), "%u:%02u", (unsigned)(seconds / 60), (unsigned)s); + else if (seconds < 36000) snprintf(buf, sizeof(buf), "%u:%02u", (unsigned)h, (unsigned)m); + else snprintf(buf, sizeof(buf), "%02u:%02u", (unsigned)h, (unsigned)m); + break; + } + return String(buf); +} + +String timerFormatHMS(uint32_t seconds) +{ + return timerClock(seconds, ClockStyle::Trimmed); +} + +// Relocated out of TimerManager, the singleton's last pure statics +// (secondsToHMS / hmsToSeconds) -- byte-identical. +void timerSecondsToHMS(uint32_t sec, uint32_t &h, uint32_t &m, uint32_t &s) +{ + h = sec / 3600; + m = (sec % 3600) / 60; + s = sec % 60; +} + +uint32_t timerHmsToSeconds(uint32_t h, uint32_t m, uint32_t s) +{ + return h * 3600UL + m * 60UL + s; +} + +// --------------------------------------------------------------------------- +// HA attribute-group projection. The buzzer + finished +// HASelects were lit up first; the JSON-attributes opt-in then extended to +// HASensor, lighting up the remaining + state sensors. realert_interval is listed +// first on the finished carrier so the folded payload is a superset of the legacy +// bespoke {"realert_interval":N}. remaining_publish_interval rides BOTH the +// remaining sensor (its own cadence) and the state sensor (a complete config view) +// -- one settings row, two carrier rows. bar_color carries the per-row formatter +// so it renders as "default"/"#RRGGBB" rather than its raw-int snapshot form. +// max_duration is the first MULTI-CARRIER key whose +// representation differs PER CARRIER: it rides the Duration text entity in +// carrier-native clock form (formatMaxDurationHMS -> "24:00:00") AND the state +// sensor in raw seconds (no formatter -> 86400) -- one settings row, two carrier +// rows, two representations over the same persisted storage. +// carrier, cmdKey, format +const TimerAttrGroupDesc TIMER_ATTR_GROUP_DESCS[] = { + {TimerHaEntity::Duration, "max_duration", formatMaxDurationHMS}, + {TimerHaEntity::Finished, "realert_interval", nullptr}, + {TimerHaEntity::Finished, "finished_hold", nullptr}, + {TimerHaEntity::Buzzer, "countdown_seconds", nullptr}, + {TimerHaEntity::Buzzer, "melody_tick", nullptr}, + {TimerHaEntity::Buzzer, "melody_end", nullptr}, + {TimerHaEntity::Remaining, "remaining_publish_interval", nullptr}, + {TimerHaEntity::State, "max_duration", nullptr}, + {TimerHaEntity::State, "remaining_publish_interval", nullptr}, + {TimerHaEntity::State, "icon_enabled", nullptr}, + {TimerHaEntity::State, "bar_enabled", nullptr}, + {TimerHaEntity::State, "bar_color", timerFormatBarColor}, + {TimerHaEntity::State, "bar_bg_color", timerFormatBarBgColor}, + {TimerHaEntity::State, "sync_follow", nullptr}, + {TimerHaEntity::State, "sync_targets", nullptr}, +}; + +const size_t TIMER_ATTR_GROUP_DESC_COUNT = + sizeof(TIMER_ATTR_GROUP_DESCS) / sizeof(TIMER_ATTR_GROUP_DESCS[0]); + +void timerBuildAttributeGroup(TimerHaEntity carrier, JsonDocument &doc) +{ + for (size_t i = 0; i < TIMER_ATTR_GROUP_DESC_COUNT; ++i) + { + const TimerAttrGroupDesc &g = TIMER_ATTR_GROUP_DESCS[i]; + if (g.carrier != carrier) continue; + const TimerSettingDesc *d = timerSettingByCmdKey(g.cmdKey); + if (!d) continue; // table self-consistency is pinned by test T14 + if (g.format) g.format(*d, doc); + else timerSettingEmitValue(*d, doc); + } +} + +// =========================================================================== +// Member-backed config half (B1) -- PURE validation projection. +// The validate predicates + the parallel {cmdKey, validate} table +// (TIMER_MEMBER_VALIDATORS) that TimerCommand::classify uses live HERE, with no +// singleton/MQTT dependency. The impure half -- the apply/emit/publish hooks, the +// full TIMER_MEMBER_CONFIG_DESCS table, and its snapshot/publish helpers -- lives in +// TimerSettingsApply.cpp (they route through TimerManager's setters and the MQTT +// seam). Both tables reference the SAME validate function pointers below, so a drift +// guard test pins them row-for-row: they cannot disagree about a member key. +// =========================================================================== +bool timerMemValidateBuzzer(JsonVariantConst v, TcValue &out) +{ + BuzzerMode m; + if (!timerParseBuzzerMode(v.as(), m)) return false; + out.num = (uint32_t)m; + return true; +} + +bool timerMemValidateFinished(JsonVariantConst v, TcValue &out) +{ + FinishedMode m; + if (!timerParseFinishedMode(v.as(), m)) return false; + out.num = (uint32_t)m; + return true; +} + +bool timerMemValidateIcon(JsonVariantConst v, TcValue &out) +{ + String s = v.as(); + if (!timerIsValidIconName(s)) return false; + out.str = s; + return true; +} + +const TimerMemberValidatorDesc TIMER_MEMBER_VALIDATORS[] = { + {"buzzer", timerMemValidateBuzzer}, + {"finished", timerMemValidateFinished}, + {"icon_idle", timerMemValidateIcon}, + {"icon_running", timerMemValidateIcon}, + {"icon_paused", timerMemValidateIcon}, + {"icon_finished", timerMemValidateIcon}, +}; + +const size_t TIMER_MEMBER_VALIDATOR_COUNT = + sizeof(TIMER_MEMBER_VALIDATORS) / sizeof(TIMER_MEMBER_VALIDATORS[0]); + +static_assert(sizeof(TIMER_MEMBER_VALIDATORS) / sizeof(TIMER_MEMBER_VALIDATORS[0]) == TIMER_MEMBER_CONFIG_DESC_CAP, + "TIMER_MEMBER_CONFIG_DESC_CAP must equal the member-config row count"); +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerSettings.h b/src/TimerSettings.h new file mode 100644 index 00000000..822ec98a --- /dev/null +++ b/src/TimerSettings.h @@ -0,0 +1,281 @@ +#ifndef TimerSettings_h +#define TimerSettings_h + +#include +#include + +#include "TimerHa.h" // TimerHaEntity (the HA carrier each attribute group rides) +#include "TimerEnums.h" // BuzzerMode / FinishedMode for the relocated enum parsers + +// Persisted Timer settings: the single descriptor table that drives validation, +// apply, NVS persistence, dev.json overrides and the propagated config snapshot +// for the value-config Timer keys. Modelled on TimerHa.h ("one table, no drift"). +// +// This table is a SUPERSET, not "the config block": the `inSnapshot` column +// codifies the config/identity boundary -- +// * config block = the inSnapshot == true rows (the propagated snapshot) +// * sync roles / local id = the inSnapshot == false rows (sync_follow/sync_targets, +// deliberately excluded from the snapshot) +// +// Out of scope (the B1 boundary): duration/buzzer/finished and the +// four icon_ keys stay on TimerManager's publish-aware setters. They are config +// block too, but their snapshot/broadcast membership is governed by the shared +// member-config list in TimerManager.cpp, not by this table. + +enum class TcType : uint8_t { U16, U32, Bool, Str }; +enum class TcCheck : uint8_t { Bool, UIntRange, Name, Bespoke }; + +// A validated + coerced value, staged before any global is written so parseCommand +// keeps its atomic-reject contract (validate everything, then apply). +struct TcValue +{ + uint32_t num = 0; + bool b = false; + String str; +}; + +struct TimerSettingDesc +{ + const char *cmdKey; // POST/MQTT + snapshot key, e.g. "finished_hold" + const char *devKey; // dev.json key, e.g. "timer_finished_hold" + const char *nvsKey; // NVS "awtrix" key, e.g. "TFHOLD" + TcType type; + TcCheck check; + uint32_t lo, hi; // UIntRange bounds (ranges live HERE, once) + uint32_t dfltNum; // NVS default for U16/U32/Bool + const char *dfltStr; // NVS default for Str; also the Name empty-substitution value + // Bespoke validators (bar_color / sync_targets only); nullptr for declarative rows. + bool (*bespoke)(JsonVariantConst, TcValue &out); + bool inSnapshot; // member of the propagated config block iff true + void *storage; // typed by `type`: uint16_t* / uint32_t* / bool* / String* +}; + +extern const TimerSettingDesc TIMER_SETTINGS_DESCS[]; +extern const size_t TIMER_SETTINGS_DESC_COUNT; + +// Compile-time row count (TIMER_SETTINGS_DESC_COUNT is only known at runtime, so it +// cannot size a fixed array). A static_assert in TimerSettings.cpp pins it equal to +// the table extent, so it cannot drift. Used by the one-shot override controller +// to stack a config snapshot buffer over the table. +constexpr size_t TIMER_SETTINGS_DESC_CAP = 13; + +// Validate + coerce one field into `out`. Never writes a global (pure parse). Strict +// JSON types match the legacy parseCommand: numbers reject bool/string/null; bools +// require a real JSON bool. Returns false on any violation. +bool timerSettingParse(const TimerSettingDesc &d, JsonVariantConst v, TcValue &out); + +// Write a previously-parsed value to the descriptor's storage (dispatch on type). +// Equality-skip: returns true iff the stored value actually changed, so callers +// can elide the "awtrix" flush for no-op writes (mirrors the member setters). +bool timerSettingStore(const TimerSettingDesc &d, const TcValue &v); + +// NVS round-trip for the whole table (caller brackets begin()/end()). +void timerSettingsLoadNvs(class Preferences &prefs); +void timerSettingsSaveNvs(class Preferences &prefs); + +// dev.json overrides: per-key best-effort (validate each present devKey, store iff +// valid, skip-and-continue otherwise -- NOT atomic; dev.json is a boot override layer). +void timerSettingsLoadDevJson(JsonObjectConst obj); + +// Emit ONE row's live value into `doc` under its cmdKey, dispatched by `type` and +// IGNORING snapshot membership. The single "storage -> JSON value" helper shared by +// the config snapshot (inSnapshot rows) and the HA attribute builder, so the two can +// never disagree about a value. +void timerSettingEmitValue(const TimerSettingDesc &d, JsonDocument &doc); + +// Emit the inSnapshot rows into `doc` (the propagated config block, table half). +void timerSettingsBuildSnapshot(JsonDocument &doc); + +// One-shot override: capture/restore the table half's config +// block (the inSnapshot rows; sync_* are inSnapshot=false and excluded by +// construction) into a caller-owned TcValue buffer of TIMER_SETTINGS_DESC_CAP slots, +// indexed by row. Capture reads each storage by type; restore writes it back via +// timerSettingStore. Generic over the table, so no per-key code is required. +void timerSettingsCaptureSnapshot(TcValue out[]); +void timerSettingsRestoreSnapshot(const TcValue in[]); + +// Lookup by command key (used by MenuManager to reuse a row's range bounds). +const TimerSettingDesc *timerSettingByCmdKey(const char *cmdKey); + +// --------------------------------------------------------------------------- +// Inline RTTTL classifier/validator -- a pure, reusable pair +// reused by command validation and melody resolution. `melody_end`/`melody_tick` +// accept EITHER a bare file-name token (as today) OR an inline RTTTL tune; the two +// are distinguished by content. An inline tune is always one-shot (it has no +// persistable file form), so it is never written to a melody name global. + +// True iff `s` is an inline RTTTL tune rather than a bare melody file-name token. +// A bare token is [A-Za-z0-9_-]* (never contains a colon); an inline tune carries +// RTTTL structure, so the presence of a ':' is the discriminator. +bool timerMelodyIsInline(const String &s); + +// Validate an inline RTTTL tune's syntax: `name:control:notes` (exactly two colons), +// a non-empty control section carrying a d=/o=/b= token, a non-empty notes section, +// within a length cap. Returns false on any violation (a malformed inline tune is +// BadField/400, atomic-reject). Name may be empty. +bool timerMelodyValidateInline(const String &s); + +// --------------------------------------------------------------------------- +// Pure validation helpers relocated out of TimerManager's statics so the +// command validator links the descriptor-table family, not the singleton. The +// singleton's thin forwarders were deleted once production callers were gone; +// these free functions are the only spellings. + +// Parse a duration string into seconds. Accepts bare seconds, MM:SS or HH:MM:SS; +// each field is a non-empty digit run; fields are summed without a 0-59 cap +// (carry); surrounding whitespace is trimmed. No clamp, no globals. Returns false +// (leaving outSeconds untouched) on empty input, an empty field, more than two +// colons, or a non-numeric field. +bool timerParseHMS(const String &s, uint32_t &outSeconds); + +// True iff `s` is a valid timer action verb -- case-insensitive, exactly one of +// {start, pause, reset}. No trim (a surrounding space rejects). No globals. +bool timerIsValidAction(const String &s); + +// More pure validators/formatters relocated into the table family so the +// command validator (TimerCommand::classify) links the family, not the singleton. + +// Bare icon/melody file-name char-rule: [A-Za-z0-9_-], length 0..32 (empty = clear, +// accepted). The validation predicate behind the table's Name check and +// TimerManager's private icon-slot validation. No globals. +bool timerIsValidIconName(const String &name); + +// String->enum over the buzzer / finished codec tables, case-insensitive +// wire spelling or alias. The enum parse behind the member validators. No globals. +bool timerParseBuzzerMode(const String &s, BuzzerMode &out); +bool timerParseFinishedMode(const String &s, FinishedMode &out); + +// The one seconds->clock renderer. Three deliberate spellings the timer +// surfaces speak, collapsed into one function: +// Trimmed drop the hours group when zero; most-significant field unpadded, lower +// fields zero-padded ("24:00:00" / "1:00:00" / "0:45"). HA/config-read form. +// Padded always zero-padded HH:MM:SS ("24:00:00" / "01:00:00" / "00:00:45"). Menu +// / config-screen form. +// Compact two segments, seconds dropped past the hour mark: M:SS (<1h), H:MM (<10h), +// HH:MM (>=10h). The running-display form. No globals. +enum class ClockStyle { Trimmed, Padded, Compact }; +String timerClock(uint32_t seconds, ClockStyle style); + +// Trimmed clock string ("24:00:00" / "1:00:00" / "0:45"). Thin alias for +// timerClock(seconds, ClockStyle::Trimmed); the Duration wire/state spelling +// (state JSON, Duration publishes/echoes, the max_duration HA formatter). No globals. +String timerFormatHMS(uint32_t seconds); + +// Raw seconds <-> H/M/S integer decomposition, relocated out of TimerManager's +// last pure statics. Pure math, no clamp (the on-device config editor — +// the one caller — owns its 99h display cap), no globals. +void timerSecondsToHMS(uint32_t sec, uint32_t &h, uint32_t &m, uint32_t &s); +uint32_t timerHmsToSeconds(uint32_t h, uint32_t m, uint32_t s); + +// --------------------------------------------------------------------------- +// HA attribute-group projection -- the descriptor-table family's fifth +// member. One row per (carrier entity, settings key) projection: a persisted +// settings value surfaced as a read-only JSON attribute on the HA entity it is +// semantically about. A key may map to MULTIPLE carriers (one row each). The +// optional `format` hook overrides the raw emit for rows whose attribute +// representation deliberately differs from the config-snapshot one (e.g. +// bar_color's "#RRGGBB" string); nullptr means emit the raw live value via +// timerSettingEmitValue. One table, one generic builder -- adding or moving a +// knob's HA projection is a one-row change with no drift across publish paths. +struct TimerAttrGroupDesc +{ + TimerHaEntity carrier; // the HA entity that carries this attribute + const char *cmdKey; // joins TIMER_SETTINGS_DESCS by cmdKey (the attribute key, verbatim) + void (*format)(const TimerSettingDesc &d, JsonDocument &doc); // nullptr = raw emit +}; + +extern const TimerAttrGroupDesc TIMER_ATTR_GROUP_DESCS[]; +extern const size_t TIMER_ATTR_GROUP_DESC_COUNT; + +// Build one carrier's attribute object into `doc`: every table row mapped to +// `carrier`, emitted under its cmdKey (via the row's formatter, else the shared +// timerSettingEmitValue). `doc` is left empty for a carrier with no mapped rows. +void timerBuildAttributeGroup(TimerHaEntity carrier, JsonDocument &doc); + +// --------------------------------------------------------------------------- +// The config block's SECOND table: the member-backed half (B1). +// +// One row per member-backed config key (buzzer / finished / the four icon_). +// Unlike TIMER_SETTINGS_DESCS' DECLARATIVE rows, these carry function-pointer hooks +// -- exactly like TIMER_MENU_SLOTS' enum slots -- because their values are owned by +// TimerManager's publish-aware setters (equality-skip, _suspendPersist batching, MQTT +// publish), not by a typed storage pointer. This is deliberately NOT folded +// into the declarative table; it is a separate hook table, the fourth member of the +// descriptor-table family (TIMER_SETTINGS_DESCS, TIMER_HA_DESCRIPTORS, TIMER_MENU_SLOTS). +// +// Together the two tables ARE the config block. `duration` is member-backed too but is +// deliberately EXCLUDED: it is run-state, not config, and its validation +// depends on the cross-field effectiveMaxDuration staged from the table half. +struct TimerMemberConfigDesc +{ + const char *cmdKey; // POST/MQTT + snapshot key + bool (*validate)(JsonVariantConst, TcValue &out); // pure: validate + coerce, no mutation + void (*apply)(const TcValue &); // routes via TimerManager's deep setter + void (*emit)(JsonDocument &doc); // writes the live value into the snapshot + void (*publish)(); // optional: live value onto the MQTT wire + // seam; nullptr = key not + // individually published +}; + +extern const TimerMemberConfigDesc TIMER_MEMBER_CONFIG_DESCS[]; +extern const size_t TIMER_MEMBER_CONFIG_DESC_COUNT; + +// Compile-time row count for the member-config half (the runtime COUNT can't size a +// fixed array), used by TimerCommand::Plan to stage member values. A static_assert +// pins it to the table extent. Bump if a member-config row is added. +constexpr size_t TIMER_MEMBER_CONFIG_DESC_CAP = 6; + +// The member-config half's PURE validation projection. TIMER_MEMBER_CONFIG_DESCS +// carries impure apply/emit/publish hooks (the TimerManager singleton + the MQTT wire +// seam), so the whole table can't link where only the dependency-light command +// validator is wanted. This parallel table carries ONLY the pure {cmdKey, validate} +// columns -- the exact same validate function pointers -- so TimerCommand::classify +// validates the member half without dragging in the apply machinery. The two +// tables stay row-for-row (same cmdKey, same validate) so they cannot +// disagree about what a member key is or how it validates. +struct TimerMemberValidatorDesc +{ + const char *cmdKey; + bool (*validate)(JsonVariantConst, TcValue &out); // pure: validate + coerce, no mutation +}; + +extern const TimerMemberValidatorDesc TIMER_MEMBER_VALIDATORS[]; +extern const size_t TIMER_MEMBER_VALIDATOR_COUNT; + +// The member-config validate predicates (pure; live in TimerSettings.cpp). Declared +// so BOTH tables reference the SAME function pointers: TIMER_MEMBER_VALIDATORS (pure +// half) and TIMER_MEMBER_CONFIG_DESCS' validate column (impure half). Sharing the +// pointer is what makes the drift guard exact -- the two tables cannot validate a key +// differently because they validate it with the same function. +bool timerMemValidateBuzzer(JsonVariantConst v, TcValue &out); +bool timerMemValidateFinished(JsonVariantConst v, TcValue &out); +bool timerMemValidateIcon(JsonVariantConst v, TcValue &out); + +// bar_color / bar_bg_color attribute formatters (external linkage, defined in the pure +// TU) shared by the attr-group table and the HTTP full-config dump. +void timerFormatBarColor(const TimerSettingDesc &d, JsonDocument &doc); +void timerFormatBarBgColor(const TimerSettingDesc &d, JsonDocument &doc); + +// Emit each member-config key's live value into `doc` (the B1 half of the config snapshot). +void timerMemberConfigBuildSnapshot(JsonDocument &doc); + +// Run `cmdKey`'s declared publish hook (no-op if the key has none / is unknown). +// TimerManager's per-key publish methods dispatch through this, so the row is the +// single place "how key X goes out on the wire" is defined. +void timerMemberConfigPublish(const char *cmdKey); + +// --------------------------------------------------------------------------- +// HTTP GET /api/timer config mirror. Build the COMPLETE persisted +// configuration into `doc`: the full two-table dump an HTTP-only client reads +// back so it can confirm everything it can POST. Unlike timerSettingsBuildSnapshot +// it does NOT honour the inSnapshot filter -- the sync-role keys +// (sync_follow/sync_targets) ARE included -- and it appends the member-config half +// (buzzer/finished/the four icon_*) via timerMemberConfigBuildSnapshot. Pure: it +// reads only the descriptor storage, no I/O, no globals beyond that. Because it +// walks the same two tables that drive the control surface, the read surface +// cannot drift from what is writable. Values are raw here; the two +// carrier-native renderings (friendly bar_color, max_duration_str) are layered +// on by the HA attribute builder. +void timerBuildFullConfig(JsonDocument &doc); + +#endif diff --git a/src/TimerSettingsApply.cpp b/src/TimerSettingsApply.cpp new file mode 100644 index 00000000..ae9fe729 --- /dev/null +++ b/src/TimerSettingsApply.cpp @@ -0,0 +1,179 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerSettings.h" + +#include +#include + +#include "TimerManager.h" // TimerManager singleton: the member-config setters/getters +#include "MQTTManager.h" // the (topic, payload) wire seam the publish hooks emit on +#include "TimerHa.h" // TimerHaEntity slots for the hooks' canonical topics + +// The IMPURE half of the descriptor-table family. Everything here touches the +// TimerManager singleton, the MQTT wire seam, or Preferences -- so it is split out +// of the pure TimerSettings.cpp, which stays dependency-light. The PURE half (the +// descriptor tables, parsers, member VALIDATORS) lives in TimerSettings.cpp; this file +// carries the member apply/emit/publish hooks, the full TIMER_MEMBER_CONFIG_DESCS +// table, the NVS round-trip, and the HTTP full-config dump. + +// =========================================================================== +// NVS round-trip for the whole settings table (caller brackets begin()/end()). +// =========================================================================== +void timerSettingsLoadNvs(Preferences &prefs) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + switch (d.type) + { + case TcType::U16: *static_cast(d.storage) = (uint16_t)prefs.getUInt(d.nvsKey, d.dfltNum); break; + case TcType::U32: *static_cast(d.storage) = prefs.getUInt(d.nvsKey, d.dfltNum); break; + case TcType::Bool: *static_cast (d.storage) = prefs.getBool(d.nvsKey, d.dfltNum != 0); break; + case TcType::Str: *static_cast (d.storage) = prefs.getString(d.nvsKey, d.dfltStr); break; + } + } +} + +void timerSettingsSaveNvs(Preferences &prefs) +{ + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + switch (d.type) + { + case TcType::U16: prefs.putUInt(d.nvsKey, *static_cast(d.storage)); break; + case TcType::U32: prefs.putUInt(d.nvsKey, *static_cast(d.storage)); break; + case TcType::Bool: prefs.putBool(d.nvsKey, *static_cast (d.storage)); break; + case TcType::Str: prefs.putString(d.nvsKey, *static_cast(d.storage)); break; + } + } +} + +// =========================================================================== +// Member-backed config half (B1) -- the impure apply/emit/publish hooks + the full +// table. Each hook routes through TimerManager's existing public setters/getters; the +// enum rows stage the enum cast in TcValue::num, the icon rows stage the name in +// TcValue::str. The validate column reuses the SAME pure predicates the validator +// table uses (timerMemValidate*, declared in the header) so the two cannot drift. +// =========================================================================== +namespace +{ + // -- buzzer -- + void memApplyBuzzer(const TcValue &v) { TimerManager.setBuzzerMode((BuzzerMode)v.num); } + void memEmitBuzzer (JsonDocument &doc) { doc["buzzer"] = TimerManager.buzzerModeString(); } + // Publish hook: the live value onto the wire seam, payload the + // per-enum codec's canonical `wire` string -- never the numeric index, and + // deliberately not the HASelect `ha` label the retired setState path sent. + void memPublishBuzzer() + { + MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::Buzzer).c_str(), + TimerManager.buzzerModeString()); + } + + // -- finished -- + void memApplyFinished(const TcValue &v) { TimerManager.setFinishedMode((FinishedMode)v.num); } + void memEmitFinished (JsonDocument &doc) { doc["finished"] = TimerManager.finishedModeString(); } + // Publish hook: see memPublishBuzzer. + void memPublishFinished() + { + MQTTManager.publishTimerWire(MQTTManager.timerWireTopic(TimerHaEntity::Finished).c_str(), + TimerManager.finishedModeString()); + } + + // -- icon_ (shared validate + shared aggregate publish; the per-slot + // apply/emit fold into these two TimerState-indexed templates over iconByState[]). + // A fifth timer state wires its apply + emit for free from its row's tag + // instead of needing a hand-written pair that could be forgotten. The + // snapshot/command key per state, ordered by TimerState (== each row's cmdKey). + static const char *const kIconCmdKeys[kTimerStateCount] = { + "icon_idle", "icon_running", "icon_paused", "icon_finished"}; + template + void memApplyIcon(const TcValue &v) { TimerManager.setIcon(S, v.str); } + template + void memEmitIcon (JsonDocument &doc) { doc[kIconCmdKeys[(size_t)S]] = TimerManager.getIcon(S); } + // Publish hook, shared by all four icon rows: the icon keys have + // ONE wire artifact — the aggregate four-state JSON on the plain + // {MQTT_PREFIX}/timer/icons topic (not an HA entity data topic), payload + // byte-identical to the retired MQTTManager::publishTimerIcons composer. + void memPublishIcons() + { + DynamicJsonDocument doc(256); + doc["idle"] = TimerManager.getIconIdle(); + doc["running"] = TimerManager.getIconRunning(); + doc["paused"] = TimerManager.getIconPaused(); + doc["finished"] = TimerManager.getIconFinished(); + String payload; + serializeJson(doc, payload); + MQTTManager.publishTimerWire(MQTTManager.timerIconsTopic().c_str(), payload.c_str()); + } +} + +const TimerMemberConfigDesc TIMER_MEMBER_CONFIG_DESCS[] = { + {"buzzer", timerMemValidateBuzzer, memApplyBuzzer, memEmitBuzzer, memPublishBuzzer}, + {"finished", timerMemValidateFinished, memApplyFinished, memEmitFinished, memPublishFinished}, + {"icon_idle", timerMemValidateIcon, memApplyIcon, memEmitIcon, memPublishIcons}, + {"icon_running", timerMemValidateIcon, memApplyIcon, memEmitIcon, memPublishIcons}, + {"icon_paused", timerMemValidateIcon, memApplyIcon, memEmitIcon, memPublishIcons}, + {"icon_finished", timerMemValidateIcon, memApplyIcon, memEmitIcon, memPublishIcons}, +}; + +const size_t TIMER_MEMBER_CONFIG_DESC_COUNT = + sizeof(TIMER_MEMBER_CONFIG_DESCS) / sizeof(TIMER_MEMBER_CONFIG_DESCS[0]); + +void timerMemberConfigBuildSnapshot(JsonDocument &doc) +{ + for (size_t i = 0; i < TIMER_MEMBER_CONFIG_DESC_COUNT; ++i) + TIMER_MEMBER_CONFIG_DESCS[i].emit(doc); +} + +void timerMemberConfigPublish(const char *cmdKey) +{ + for (size_t i = 0; i < TIMER_MEMBER_CONFIG_DESC_COUNT; ++i) + { + const TimerMemberConfigDesc &d = TIMER_MEMBER_CONFIG_DESCS[i]; + if (strcmp(d.cmdKey, cmdKey) != 0) continue; + if (d.publish) d.publish(); + return; + } +} + +// =========================================================================== +// HTTP GET /api/timer config mirror. The complete persisted-config +// projection: both tables, no snapshot filter, raw values. Lives beside the impure +// member half it dumps. See the header for the full contract. +// =========================================================================== +void timerBuildFullConfig(JsonDocument &doc) +{ + // Table half: EVERY settings row, ignoring inSnapshot, so the sync-role keys + // (sync_follow/sync_targets) are part of the read mirror even though they are + // never propagated. Raw value per row via the shared single-row emitter, with + // the two deliberate carrier-native overrides reusing the family's + // shared bar formatters -- diverging from the raw propagation snapshot by design, + // yet never able to disagree in value (same storage). + for (size_t i = 0; i < TIMER_SETTINGS_DESC_COUNT; ++i) + { + const TimerSettingDesc &d = TIMER_SETTINGS_DESCS[i]; + if (strcmp(d.cmdKey, "bar_color") == 0) + { + timerFormatBarColor(d, doc); // "default" / uppercase "#RRGGBB", not the raw int + } + else if (strcmp(d.cmdKey, "bar_bg_color") == 0) + { + timerFormatBarBgColor(d, doc); // "none" / uppercase "#RRGGBB", not the raw int + } + else if (strcmp(d.cmdKey, "max_duration") == 0) + { + timerSettingEmitValue(d, doc); // raw seconds kept (e.g. 86400) + // ...plus a trimmed clock-string sibling, this endpoint's raw+_str + // duration precedent, reusing the exact timerFormatHMS the duration fields use. + doc["max_duration_str"] = timerFormatHMS(*static_cast(d.storage)); + } + else + { + timerSettingEmitValue(d, doc); + } + } + + // Member-backed half: buzzer/finished + the four icon_* live values. + timerMemberConfigBuildSnapshot(doc); +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerView.cpp b/src/TimerView.cpp new file mode 100644 index 00000000..d6f387a5 --- /dev/null +++ b/src/TimerView.cpp @@ -0,0 +1,91 @@ +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerView.h" + +#include "TimerSettings.h" // timerClock / ClockStyle + +#include + +namespace +{ + // Display geometry owned by the view (app-local coordinates on the 32x8 + // panel). The painter (src/Apps.cpp) keeps only the font-/row-dependent bits + // (text baseline, bottom row, config underline step). + constexpr int16_t kScreenW = 32; + constexpr int16_t kTextX = 8; // text region starts right of the 8px icon + constexpr int16_t kTextWidth = 24; + constexpr int16_t kBarMaxLen = 23; + constexpr int16_t kBarX0 = 9; + + constexpr unsigned long kBlinkMs = 500; +} + +void TimerViewModel::formatTimerDisplay(uint32_t seconds, char *out, size_t outLen) +{ + snprintf(out, outLen, "%s", timerClock(seconds, ClockStyle::Compact).c_str()); +} + +TimerView TimerViewModel::compute(const TimerSnapshot &s) +{ + TimerView v = {}; + + const TimerState ts = s.state; + + // Non-config screens draw the icon (unless disabled) and center their text in + // the 24px area right of it. When the icon is hidden, text reflows to the full + // 32px panel. + v.showIcon = s.iconEnabled; + if (s.iconEnabled) { v.textRegionX0 = kTextX; v.textRegionW = kTextWidth; } // 8 / 24 + else { v.textRegionX0 = 0; v.textRegionW = kScreenW; } // full 32px + + // Finished screen: blinking "0:00", no bar. + if (ts == TimerState::Finished) + { + v.screen = TimerView::Screen::Finished; + snprintf(v.text, sizeof(v.text), "0:00"); + v.showText = ((s.nowMs / kBlinkMs) % 2 == 0); + return v; + } + + // Time screen: Idle shows the configured duration; Running/Paused show remaining. + v.screen = TimerView::Screen::Time; + const uint32_t duration = s.duration; + const uint32_t remaining = (ts == TimerState::Idle) ? duration : s.remaining; + formatTimerDisplay(remaining, v.text, sizeof(v.text)); + v.showText = true; + + // The bar divides by the duration captured when the run began, not the live + // configured duration, so editing the duration mid-run leaves the in-progress + // bar untouched (it re-arms on the next start/reset). Falls back to the + // configured duration if no snapshot. + uint32_t barDuration = s.runDuration; + if (barDuration == 0) barDuration = duration; + if (ts != TimerState::Idle && barDuration > 0) + { + // The bar also reflows when the icon is hidden: it spans the full panel + // instead of the 23px region right of the icon. Right edge stays anchored + // at col 31 either way (barX0 + barMaxLen == kScreenW). + const int16_t barX0 = s.iconEnabled ? kBarX0 : 0; // 9 or 0 + const int16_t barMaxLen = s.iconEnabled ? kBarMaxLen : kScreenW; // 23 or 32 + + // The background track is the full bar trough and persists for the whole + // Running/Paused window, independent of the foreground's len-rounds-to-0 + // gate below. The painter AND-s in TIMER_BAR_ENABLED / a non-black + // bar_bg_color, exactly as it does for the foreground. + v.showBarTrack = true; + v.barTrackLen = (uint8_t)barMaxLen; + v.barTrackStartX = barX0; + + uint32_t len = ((uint32_t)barMaxLen * remaining) / barDuration; + if (len > (uint32_t)barMaxLen) + len = (uint32_t)barMaxLen; + if (len > 0) + { + v.showBar = true; + v.barLen = (uint8_t)len; + v.barStartX = barX0 + (barMaxLen - (int16_t)len); // right edge anchored at col 31 + } + } + + return v; +} +#endif // AWTRIX_DISABLE_TIMER diff --git a/src/TimerView.h b/src/TimerView.h new file mode 100644 index 00000000..2c54a3e7 --- /dev/null +++ b/src/TimerView.h @@ -0,0 +1,86 @@ +#ifndef TimerView_h +#define TimerView_h + +#include + +#include "TimerEnums.h" // TimerState (carried by TimerSnapshot, no TimerManager dependency) + +// The explicit, per-frame input to TimerViewModel::compute(): a plain-data +// snapshot of everything the view reads, captured once from TimerManager by the +// painter (src/Apps.cpp). It carries no methods and no TimerManager dependency, +// so compute() is a pure function of this value. +struct TimerSnapshot +{ + TimerState state; // lifecycle state (Idle/Running/Paused/Finished) + uint32_t duration; // configured duration (seconds) + uint32_t remaining; // live remaining (seconds); compute uses it only when !Idle + uint32_t runDuration; // bar-denominator snapshot captured when the run began + bool iconEnabled; // TIMER_ICON_ENABLED: gates the icon + full-panel reflow + unsigned long nowMs; // millis(); drives the 500 ms Finished blink only +}; + +// A pure, per-frame description of what the Timer app should draw. It holds no +// pixels and no font metrics: it is fully determined by TimerManager state, the +// current time (for the Finished blink), and the icon-enabled display flag (which +// gates the icon and, when off, reflows text+bar to the full panel). TimerApp +// (src/Apps.cpp) is its painter — it maps this struct to DisplayManager / matrix +// calls and owns the font-dependent text centering. Keeping the view display-free +// keeps the bar geometry, blink cadence and display-string selection +// independently checkable instead of hidden in the renderer. +struct TimerView +{ + enum class Screen : uint8_t { Config, Finished, Time }; + Screen screen; + + // Whether to draw the timer icon (false on Config, or when icon_enabled is off). + // When false, text and bar reflow to span the full 32px panel. + bool showIcon; + + // Text to draw, centered by the painter within [textRegionX0, +textRegionW). + char text[12]; + bool showText; // false only during the Finished-blink "off" phase + int16_t textRegionX0; // app-local left edge of the centering region + int16_t textRegionW; // width of the centering region + + // Progress bar (Time screen, running/paused, duration > 0). Right-anchored, + // drains from the left: invariant barStartX + barLen == panel width (32). Its + // max length depends on showIcon (icon on -> 23px right of the icon; icon off + // -> the full 32px panel). + bool showBar; + uint8_t barLen; // 0 .. (kBarMaxLen, or kScreenW when the icon is hidden) + int16_t barStartX; // app-local start column + + // Background track behind the bar: the FULL bar extent, painted in + // bar_bg_color before the foreground segment. Unlike showBar it PERSISTS for the + // whole Running/Paused window -- including the final stretch where barLen rounds + // to 0 and showBar is false -- so the trough stays visible like the app's + // drawProgressBar background. Same reflow as the bar: icon on -> (9, 23), icon + // off -> (0, 32). + bool showBarTrack; + uint8_t barTrackLen; // kBarMaxLen, or kScreenW when the icon is hidden + int16_t barTrackStartX; // app-local start column (bar origin at full length) + + // Config-mode field underline (Config screen only). + bool showUnderline; + uint8_t underlineField; // 0 = HH, 1 = MM, 2 = SS (painter maps to X) +}; + +namespace TimerViewModel +{ + // Compute the view from an explicit snapshot of the timer state. Every value + // compute() reads comes from `s` — it never touches the TimerManager singleton. + // `s.nowMs` drives the 500 ms Finished blink only; `s.iconEnabled` + // (TIMER_ICON_ENABLED) gates the icon and, when false, reflows text + bar to + // the full panel. + TimerView compute(const TimerSnapshot &s); + + // The compact on-screen format, fitted to the 24px text region: + // < 1h -> "M:SS" (305 -> "5:05") + // 1..9h -> "H:MM" (seconds dropped to fit; 3661 -> "1:01") + // >= 10h -> "HH:MM" (36000 -> "10:00") + // Distinct from timerFormatHMS (the "H:MM:SS" wire string, which + // always carries seconds). + void formatTimerDisplay(uint32_t seconds, char *out, size_t outLen); +} + +#endif diff --git a/src/icons.h b/src/icons.h index 290d308a..8b19648d 100644 --- a/src/icons.h +++ b/src/icons.h @@ -22,4 +22,5 @@ const uint16_t icon_1158[] = {1087, 0, 0, 1087, 1087, 0, 0, 1087, 1087, 1087, 10 const uint16_t icon_234[] = {0, 65535, 65535, 65535, 0, 0, 0, 0, 0, 65535, 0, 65535, 0, 0, 0, 0, 0, 65535, 63488, 65535, 0, 0, 0, 0, 0, 65535, 63488, 65535, 0, 0, 0, 0, 0, 65535, 63488, 65535, 0, 0, 0, 0, 65535, 63488, 63488, 63488, 65535, 0, 0, 0, 65535, 63488, 63488, 63488, 65535, 0, 0, 0, 0, 65535, 65535, 65535, 0, 0, 0, 0}; const uint16_t icon_2075[] = {0, 0, 61279, 0, 0, 0, 0, 0, 0, 65535, 59199, 50751, 0, 0, 0, 0, 65535, 61279, 59199, 50751, 44383, 0, 0, 0, 61279, 59199, 59199, 44383, 44383, 0, 0, 0, 61279, 50751, 50751, 44383, 31737, 0, 0, 0, 0, 44383, 44383, 31737, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; const uint16_t icon_1486[] = {0, 0, 2016, 2016, 2016, 0, 0, 0, 0, 2016, 2016, 2914, 2016, 2016, 0, 0, 0, 2016, 2914, 2914, 2914, 2016, 0, 0, 0, 2016, 2914, 2914, 2914, 2016, 0, 0, 0, 2016, 2914, 2914, 2914, 2016, 0, 0, 0, 2016, 2914, 2914, 2914, 2016, 0, 0, 0, 2016, 2914, 2914, 2914, 2016, 0, 0, 0, 2016, 2016, 2016, 2016, 2016, 0, 0}; +const uint16_t icon_timer[] = {0, 59196, 59196, 59196, 59196, 59196, 0, 0, 0, 59196, 3519, 3519, 3519, 59196, 0, 0, 0, 59196, 3519, 3519, 3519, 59196, 0, 0, 0, 0, 59196, 3519, 59196, 0, 0, 0, 0, 0, 0, 59196, 0, 0, 0, 0, 0, 0, 59196, 0, 59196, 0, 0, 0, 0, 59196, 0, 0, 0, 59196, 0, 0, 0, 59196, 59196, 59196, 59196, 59196, 0, 0}; #endif \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index ab5377c6..181170fa 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -35,10 +35,16 @@ #include "DisplayManager.h" #include "PeripheryManager.h" #include "MQTTManager.h" +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerHaHost.h" +#endif #include "ServerManager.h" #include "Globals.h" #include "UpdateManager.h" #include "timer.h" +#ifndef AWTRIX_DISABLE_TIMER +#include "TimerManager.h" +#endif TaskHandle_t taskHandle; volatile bool StopTask = false; @@ -69,6 +75,9 @@ void setup() PeripheryManager.setup(); ServerManager.loadSettings(); DisplayManager.setup(); +#ifndef AWTRIX_DISABLE_TIMER + TimerManager.setup(); +#endif DisplayManager.HSVtext(9, 6, VERSION, true, 0); delay(500); xTaskCreatePinnedToCore(BootAnimation, "Task", 10000, NULL, 1, &taskHandle, 0); @@ -100,6 +109,9 @@ void setup() if (MQTT_HOST != "") { DisplayManager.HSVtext(4, 6, "MQTT...", true, 0); +#ifndef AWTRIX_DISABLE_TIMER + TimerHaHost.reconcile(); +#endif MQTTManager.setup(); MQTTManager.tick(); } @@ -119,9 +131,16 @@ void loop() timer_tick(); ServerManager.tick(); DisplayManager.tick(); +#ifndef AWTRIX_DISABLE_TIMER + TimerManager.tick(); + TimerManager.tickPresence(millis()); // peer presence beacon + registry aging +#endif PeripheryManager.tick(); if (ServerManager.isConnected) { MQTTManager.tick(); +#ifndef AWTRIX_DISABLE_TIMER + TimerHaHost.refreshTargets(millis()); // dynamic HA Targets select republish +#endif } }