Skip to content

feat(filtration): autonomous ESPHome filtration scheduling#6

Merged
gaetanars merged 36 commits intomainfrom
feat/esphome-standalone-filtration
Apr 22, 2026
Merged

feat(filtration): autonomous ESPHome filtration scheduling#6
gaetanars merged 36 commits intomainfrom
feat/esphome-standalone-filtration

Conversation

@gaetanars
Copy link
Copy Markdown
Owner

@gaetanars gaetanars commented Apr 7, 2026

Summary

This PR adds the full autonomous filtration scheduling system plus a timed forced mode for pool treatments.

Autonomous filtration scheduling (packages/filtration.yaml)

  • Mode Off / Hiver / Courbe / Auto with temperature-based duration calculation
  • Two daily filtration cycles (morning + evening) with configurable pivot hour, pause duration, and ratio
  • All scheduling computed by the ESP — no HA automations required
  • Antifreeze override (works without NTP)
  • NTP guard + HA persistent notification on sync loss

Timed forced mode (this addition)

  • Three preset buttons — Forcer Filtration 2h / 6h / 24h — immediately turn the pump ON for a fixed duration regardless of scheduling mode, then automatically return to normal scheduling
  • Arrêter Mode Forcé button cancels early; scheduling resumes within 30 s
  • Mode Forcé — Temps restant text sensor showing "Xh Ymin" / "Ymin" / "Inactif"
  • Decrement counter (g_forced_remaining_s, not NTP timestamp) — works offline, mirrors antifreeze resilience
  • Placed after antifreeze, before NTP guard — works even when HA is unavailable
  • _calcul_filtration guarded at cycle boundaries during active forced mode

Other changes

  • HA Lovelace dashboard (homeassistant/dashboard/frangipool.yaml)
  • README rewritten to document autonomous filtration and forced mode

Key design decisions

Decision Rationale
Decrement counter (not NTP timestamp) NTP loss during 24 h treatment would leave pump stuck ON
Forced mode before NTP guard Works when HA/internet is offline
Buttons, not persistent select One-shot action; buttons express this better than a new mode
restore_value: false + pump RESTORE_DEFAULT_OFF Pump always OFF at reboot regardless of global state

Testing notes

ESPHome firmware has no unit test framework. Manual verification:

  • Press any preset → pump ON immediately, countdown visible within 1 s
  • Mode = Off → forced mode still activates pump ✓
  • NTP not synced → forced mode still activates pump ✓
  • Natural expiry: pump returns to scheduling within 30 s
  • Stop button: countdown shows "Inactif" immediately, pump off within 30 s
  • Reboot during forced mode: pump stays OFF (RESTORE_DEFAULT_OFF)

Post-Deploy Monitoring & Validation

Validation on first flash:

  • ESPHome web UI shows 3 new force buttons + stop button + countdown sensor
  • Press "Forcer Filtration 2h" → pump ON immediately, sensor shows "2h 0min"
  • After 30 s → sensor shows "1h 59min"
  • Press "Arrêter Mode Forcé" → sensor shows "Inactif" within 1 s

ESPHome logs to watch:

  • No compilation errors for new entity IDs
  • No [W] logs during normal forced mode operation

Failure signals:

  • Pump stays ON after expiry → check g_forced_remaining_s via HA or web UI
  • Sensor stuck on "Inactif" despite active forced mode → component.update call path issue

Rollback: Flash previous firmware via OTA or serial. Pump defaults OFF at boot.


🤖 Generated with Claude Code · Model: claude-sonnet-4-6

gaetanars and others added 30 commits April 7, 2026 14:28
- packages/base.yaml: migrate time source from HA to SNTP, add web_server port 80
- packages/filtration.yaml: new package with full autonomous scheduling logic
  - 4 modes: Off / Hiver / Courbe / Auto (hysteresis ±1°C around 16°C)
  - 2 daily cycles (morning + evening) split by configurable pause centered on pivot
  - Configurable: coefficient, pivot, pause duration, morning ratio, winter params
  - NTP guard: pump off until synced, single HA alert notification
  - End-of-cycle recalculation (phase 1→0 and 2→0) for temperature-aware scheduling
  - Antifreeze override preserved with priority over all scheduling
- All 8 entry files: add filtration package (local !include for local testing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…red)

SNTP requires internet access. platform: homeassistant syncs from the local
HA instance over WiFi — no internet dependency. Once synced, the ESP maintains
time internally via its hardware clock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P1 — bugs bloquants :
- Clamp pause_start/pause_end dans [0,1439] avant de dériver les fenêtres
  (h_fin1 pouvait être négatif → cycle matin silencieusement absent)
- Inverser l'ordre dans _ntp_alert_once : HA action en premier, flag ensuite
- Remplacer !include local par github://...@main dans les 8 presets

P2 — corrections :
- Déplacer le check antigel avant le guard NTP dans l'interval
  (antigel actif + NTP invalide ne bloque plus la pompe)
- Ajouter g_cycle_phase = 0 dans le bloc Mode Off de _calcul_filtration
- Documenter le comportement de mode: queued, max_runs: 1
- Utiliser ${friendly_name} dans le titre de la notification NTP

P3 — qualité et observabilité :
- Nommer la constante 1439 → MAX_MIN avec commentaire
- Documenter le fallback NaN→10°C et les seuils 15/16/17°C du mode Auto
- Documenter le comportement de calc_courbe aux hautes températures
- Ajouter bouton "Recalcul Filtration" (force recalcul depuis HA)
- Ajouter sensors diagnostiques : Horaires Filtration, Phase Filtration,
  Durée Filtration Journalière, Mode Auto Actif
- Ajouter CI GitHub Actions : esphome config sur les 8 presets,
  avec substitution des packages distants par les fichiers locaux du commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
La filtration est désormais gérée directement par l'ESP (packages/filtration.yaml).
Le package HA et le blueprint sont obsolètes et leur présence prête à confusion.
Le README documente la migration depuis le blueprint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dashboard page unique avec coloration contextuelle pH/Redox,
graphiques 7j apexcharts-card avec zones colorées, paramètres
de filtration et électrolyseur. Sections conditionnelles
adaptées à tous les presets (salt_minimal → salt_booster_full).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New global g_forced_remaining_s (int, restore_value: false) decremented
  30 s per interval tick; guarantees no persistence across reboots (R8)
- New text sensor forced_countdown shows "Xh Ymin" / "Ymin" / "Inactif" (R9)
- Four buttons: Forcer Filtration 2h / 6h / 24h + Arrêter Mode Forcé
  Each preset immediately turns pump ON and arms the countdown (R1, R2, R3, R6)
- 30 s interval: forced mode block inserted after antifreeze, before NTP guard
  so it functions without time-sync (R4, R5, R10)
- _calcul_filtration calls at cycle boundaries guarded by g_forced_remaining_s == 0
  to suppress spurious recalculation during active forced mode (R4)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a "Mode forcé" section under Filtration autonome explaining the three
preset buttons (2h / 6h / 24h), the Stop button, countdown sensor, priority
over all modes including Off, and no-persistence-on-reboot behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single Consigne Redox (default 730 mV, range 680–760 mV) replaces the
two separate min/max entities. The 30 mV hysteresis is now hardcoded in
the firmware (ON at setpoint-30, OFF at setpoint).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Pin ESPHome to 2026.3.3 in CI (was unpinned, risking silent breakage)
- Normalize restore_value/optimistic to True/False (match base.yaml convention)
- Arrêter Mode Forcé: turn pump off immediately on press (was deferred 30s)
- filtration_phase: update_interval never + explicit updates on phase transitions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rn on empty windows

Forced mode counter now decrements before the antifreeze check so the 24h/6h/2h
countdown elapses correctly even when antifreeze is simultaneously active. Without
this fix, an overnight temperature drop would suspend the countdown indefinitely.

Also add ESP_LOGE validation after window calculation: logs an error if pivot/pause
configuration causes a zero-duration morning or evening window (e.g. pivot=00:30
with pause=6h), giving the operator visibility instead of silent missed filtration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ve web_server & Improv

Close unauthenticated LAN attack surface in packages/base.yaml: any device on
the LAN could previously trigger the pump for 24 h, force the electrolyser
into continuous chlorine generation, modify ORP setpoints, or flash arbitrary
firmware (see todo 001).

- packages/base.yaml: add wifi.ssid/password + wifi.ap.password + api.encryption.key + ota.password via !secret; remove improv_serial, esp32_improv, web_server; keep captive_portal for AP fallback recovery.
- secrets.example.yaml: new template with validation-passing placeholders (32-byte base64 API key, 8+ char AP password).
- .gitignore: exclude secrets.yaml and todos/.
- .github/workflows/validate.yml: seed secrets.yaml from example before esphome config (esphome has no skip-secrets flag).
- README.md: remove "Interface web locale" section, update packages/base.yaml description, add Secrets section covering first-flash USB requirement, key generation, loss recovery, and captive portal fallback.

No deployed fleet → no migration path; breaking OTA/API compat is acceptable.
Validated locally with `esphome config` (2026.4.1) on all 8 CI matrix presets.

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.46.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
Remove duplicate pump-write paths in packages/base.yaml so filtration.yaml
becomes the sole actuator for the pump switch (see todo 002).

- Delete unconditional `switch.turn_off: pump` from the antifreeze on_release
  handler. Caused a ~30s pump drop when antifreeze released mid-cycle during
  a 24h forced chemical treatment, before the filtration interval corrected it.
- Delete the 30s antifreeze interval that redundantly turned the pump ON
  in parallel with filtration.yaml:458-461. The two unsynchronized 30s loops
  could assert conflicting states within the same second, causing relay chatter.
- Preserve the HA notification "Fin du mode hors-gel" on on_release.
- Preserve the `on_turn_on` handler on the pump switch (updates
  `pump_last_turn_on` for the pump_uptime sensor).

All 8 CI matrix presets include filtration.yaml, so the belt-and-suspenders
approach (keeping base.yaml as fallback) wasn't worth the chatter cost.

Validated `esphome config` on the 8 presets locally.

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.46.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
Three parallel regulation paths in packages/redox_electrolyser.yaml
contradicted each other: the on_value_range below-setpoint-30 trigger fired
the electrolyser ON immediately on any dip, bypassing the 30-min
redox_stable_minutes gate that the interval regulator enforced. The stability
gate was effectively dead code (see todo 003).

Policy chosen: asymmetric — fast OFF (overdose protection), slow ON (sensor
noise filter).

- Delete the on_value_range `below: setpoint-30 → turn_on` path. This was
  the conflicting trigger; the interval regulator becomes the sole ON
  authority with its 30-min stability gate actually enforced.
- Keep on_value_range `above: setpoint → turn_off` as the fast overdose
  protection (responds in seconds to threshold crossings).
- Keep the interval's OFF block as a backstop for cases without a threshold
  crossing (e.g. Off → Auto while ORP is already above setpoint).
- Document the asymmetric policy in a 9-line comment at the top of the file
  so future readers understand that the stability gate is load-bearing.

Rationale: over-chlorination is the worse failure mode in a pool; delaying
recovery by 30 min after a real ORP drop is tolerable given chlorine
kinetics are slow anyway.

Validated `esphome config` on the 8 preset configs locally.

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.46.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
…filtration

Optimistic + restore_value number templates can return NaN if NVS is
corrupted or the partition layout shifts before initial_value is reapplied.
NaN propagating through the lambda could produce an implementation-defined
(int)NaN cast on Xtensa — typically INT_MIN — overflowing the window
arithmetic downstream. See todo 004.

- Add a safe_f helper and sanitize all 5 template-number reads:
  coeff (100), ratio (33), pause (8h), hiver_min (3h), diviseur (3).
  Fallbacks match each template's initial_value.
- Add an extra `divisr < 0.5` guard against divide-by-zero in calc_hiver.
- Add a lower clamp `dur_total_min >= 0` alongside the existing upper clamp,
  as a belt-and-suspenders safety net against any NaN that escapes the
  upstream guards.
- Add a defensive clamp on pivot_min (datetime .hour/.minute are uint8_t
  and cannot be NaN, but corrupt NVS could return out-of-range values).
  Fallback = 13:30 (initial_value).

Validated `esphome config` on the 8 preset configs locally.

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.46.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
If the DS18B20 sensor is still unsynced on the very first boot, pool_temp
reads NaN and falls back to 10 °C. In Auto mode, the first-boot initializer
would then set g_auto_submode = true (Hiver) and g_auto_initialized = true —
locking the seasonal submode until water temp actually rises above 17 °C.
For a summer deployment, this can persist for the entire season (see todo 005).

- Track a `temp_valid` flag alongside the existing pool_temp NaN fallback.
- In the Auto first-boot branch, only persist the seasonal submode when
  `temp_valid` is true. Otherwise pick a transient default (Courbe — safer
  for the common summer-deployment case) and leave g_auto_initialized false
  so the next recalc with a valid temperature reading does the real init.

Validated `esphome config` on the 8 preset configs locally.

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.46.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
…on, scoped PR trigger

Apply standard GitHub Actions hardening to .github/workflows/validate.yml
(see todo 006):

- Add `permissions: contents: read` at workflow level. Validation only needs
  to read the repo; deny the default write permissions.
- Pin actions/checkout to commit SHA (v4.2.2) with a trailing version
  comment, so Dependabot can still update it but we're not floating on the
  major tag.
- Scope the pull_request trigger to PRs targeting main, matching the push
  trigger.

No functional change to the validation itself.

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.46.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
…e force buttons

The four force-mode buttons (2h / 6h / 24h / stop) each reimplemented the
same 3-line lambda: set g_forced_remaining_s, toggle the pump, update the
countdown sensor. Any future change to the force-mode semantics (log,
HA notification, additional state reset) would need to be replicated four
times (see todo 007).

- Add a parameterized script `_start_forced_mode(hours: int)` that
  centralizes the three side effects. hours=0 cancels (turn pump off);
  hours>0 activates (turn pump on).
- Collapse each of the four button on_press handlers to a single
  `script.execute: _start_forced_mode, hours: N` call.

Validated `esphome config` on the 8 preset configs locally.

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.46.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
packages/electrolyser.yaml maintained two globals that were written but
never read anywhere in the codebase (see todo 008):

- electrolyser_last_turn_on (updated on on_turn_on)
- effective_electrolysis_minutes (incremented by a 1-min interval while
  pump + electrolyser are both on, reset on on_turn_off)

Repo-wide grep confirms no readers — no HA dashboard binding, no
blueprint (since removed), no other package consumes them. Pure dead
code. If "minutes produced today" turns out useful later, re-adding
it as a `sensor` is cheaper than carrying the dead tracking forever.

File shrinks from 40 to 9 lines — just the GPIO switch definition.

Validated `esphome config` on the 8 preset configs locally.

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.46.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
Two related cleanups in packages/filtration.yaml (todos 009 + 010).

009 — forced_countdown.update() was firing every 30s inside the forced-mode
decrement block, but the rendered text is minute-granular ("Xh Ymin").
Half the publishes re-rendered the same string. Gate the update on
`% 60 == 0` so it only fires at minute boundaries (and on expiry when
remaining reaches 0, which is 0 mod 60).

010 — three behaviour-neutral simplifications:

- Remove the `filtration_recalculate` button. Every number/select/datetime
  in the package already triggers `_calcul_filtration` via `on_value`, and
  the scheduler also re-enters it at cycle boundaries + NTP sync. The
  manual button adds no reachable capability.
- Remove the `g_dur_total_min` global. The `filtration_duree_totale`
  diagnostic sensor now computes the total directly from the four window
  bounds: `(h_fin1 - h_debut1) + (h_fin2 - h_debut2)`.
- Remove the two `if (g_forced_remaining_s == 0)` guards in the
  "Hors fenêtres" branch. Control flow only reaches that branch when the
  earlier `if (g_forced_remaining_s > 0) { pump.on(); return; }` did not
  fire — meaning forced is already 0. The guards were always-true.
  A short comment now documents the invariant.

Net -12 lines. Validated `esphome config` on the 8 preset configs locally.

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.46.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
Previously the entire docs/ tree was gitignored. Remove that rule and
commit the decision artifacts produced alongside the PR #6 fixes:

- docs/brainstorms/ — requirements docs for the three p1 decisions
  (LAN hardening, pump authority consolidation, redox regulation policy)
- docs/plans/ — structured implementation plan for the LAN hardening fix
- docs/solutions/ — first compound learning capturing the asymmetric
  redox regulation policy (fast OFF / slow ON)

Rationale: documenting the "why" behind these architectural decisions
is high-compound-value. The redox policy in particular has non-obvious
chemistry rationale that would be hard to recover from the code alone.

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v2.46.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
- Dashboard: remove broken `button.frangipool_recalcul_filtration`
  reference; add Mode Forcé card exposing the 4 force buttons and the
  countdown sensor (PR #6 headline feature was previously invisible in
  the UI).
- `_start_forced_mode`: clamp `hours` to [0, 24] to block int32 overflow
  (`hours*3600` wraps at ~596 k) and runaway pump lock-on via direct HA
  action calls bypassing the UI.

Resolves todos 011, 012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`packages/filtration.yaml` introduced the `g_` prefix for 9 package-owned
globals; the other packages kept bare names. Retrofit so the convention
is uniform across `packages/` and document the rule in README.

- base.yaml: pump_last_turn_on, store_pool_temp
- ph.yaml: store_pool_ph, ph_offset (entity id `ph_offset_sensor` preserved)
- redox.yaml: store_pool_redox, redox_offset, last_redox_trend_value,
  redox_trend_state (entity ids `redox_offset_sensor` and
  `redox_manual_offset` preserved — different namespace)
- redox_electrolyser.yaml: redox_stable_minutes

Filtration.yaml was already compliant; 22 `id(...)` call sites updated
across the other four packages.

Resolves todo 019.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `api: actions:` block exposes `force_filtration(hours)` and
  `recalc_filtration` as HA-callable services so agents and automations
  can invoke arbitrary-duration forces or trigger manual recalcs
  (hours clamp from #012 is inherited via `_start_forced_mode`).
- `esphome.min_version: 2024.6.0` pinned — matches the `datetime:`
  platform introduced in ESPHome 2024.6 used by `packages/filtration.yaml`.
  README gains a `## Prérequis` note.
- Pump switch tagged `entity_category: diagnostic` with a one-line
  comment pointing at `packages/filtration.yaml` as the authoritative
  writer. Signals to agents/HA UI that direct `switch.turn_on/off`
  calls are volatile (reasserted by the 30s scheduler tick).

Resolves todos 014, 021, 023.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…simplify

- `_ntp_alert_once` wrapped in `api.connected` so the flag only latches
  when HA actually received the notification; next tick retries if HA
  was offline (comment vs. behaviour mismatch resolved).
- `filtration_phase` now refreshes on Off-mode entry (transition guard)
  and at every `_calcul_filtration` tail (new `component.update` call).
  `filtration_auto_mode_actif` stays fresh via the existing tail update
  (bascule happens inside recalc).
- `_calcul_filtration` uses `mode: restart` instead of `mode: queued` +
  `max_runs: 1`. Matches the original intent (coalesce bursts to the
  latest value); eliminates dropped-event warnings on slider drags.
- Four force buttons lose `entity_category: config` — they're
  operational, not calibration; HA no longer hides them under
  Configuration.
- NaN defense in `_calcul_filtration` collapsed from four tiers to one:
  `safe_f` helper deleted, along with the `divisr < 0.5`, post-cast
  `dur_total_min < 0`, and pivot clamps — all guarded states the
  ESPHome template + uint8_t arithmetic cannot produce. Kept the
  legitimate upper clamp (`MAX_MIN`) and window end-clamps.
- `_start_forced_mode(hours=0)` no longer unconditionally calls
  `pump.turn_off()`; it resets the counter and lets the next 30s tick
  decide based on schedule/antifreeze. Prevents mid-cycle pump outage
  + electrolyser warmup reset when the user presses "Arrêter".
- New numeric sensor `forced_remaining_seconds` alongside the existing
  `forced_countdown` string sensor so HA automations can template on
  seconds-remaining without parsing French.
- Misc simplifications: merged identical phase-1/phase-2 post-cycle
  branches, dropped the pointless `if (g_ntp_alert_sent)` wrap, moved
  Off-mode check before NTP guard (Off doesn't need a clock), rounded
  up minutes in `forced_countdown` to eliminate the "< 1min" branch,
  trimmed apologetic/redundant comments.

Resolves todos 015, 016, 017, 018, 022, 024, 025 (filtration portion),
026.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…x annotations

- Six `conditional` cards had `state_not: unavailable` + `state_not: unknown`
  on the same entity; HA surfaces missing template entities as `unavailable`
  only, so the second check is dead weight.
- pH and Redox apexcharts encoded threshold values twice — once in
  per-series `color_threshold`, once in `apex_config.annotations.yaxis`
  background bands. Dropped the `apex_config` subtree (was only
  annotations); `color_threshold` remains the single source of truth
  for threshold colouring, eliminating drift.

−77 lines. No behaviour change other than simpler maintenance.

Resolves todo 025 (dashboard portion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the trap surfaced while fixing `_calcul_filtration`:
`mode: queued` + `max_runs: 1` does NOT coalesce burst triggers — it
keeps one running + one queued and *drops the rest with a warning*.
For a pure recalc script, `mode: restart` is the coalesce-to-latest
primitive most authors actually want.

Includes a decision rule table for picking among restart / single /
queued / parallel so the mistake doesn't recur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Landing eleven fixes surfaced by the multi-persona code review:

Safety
- redox_electrolyser: Auto on_value no longer bypasses the 30-min
  stability gate; the interval regulator becomes the only turn_on path
  (resets g_redox_stable_minutes, keeps fast-OFF above setpoint).
- base (antigel): std::isnan(t) guard preserves last known state on
  Dallas sensor failure — prevents frost damage when pipe_temp returns
  NaN.

Supply chain
- Rewrite github://frangipool/esphome-config/ → github://gaetanars/FrangiPool/
  across the 8 salt_*.yaml presets, README badges + tables, and the CI
  sed pattern. Namespace now matches the actual repository owner.

Contract / migration
- Move api.actions (force_filtration, recalc_filtration) from
  packages/base.yaml to packages/filtration.yaml — package owns its
  API surface, removes the undeclared cross-package dependency.
- Add contract comment on force_filtration + ESP_LOGW when the caller
  passes hours outside [0,24]; document that hours=0 cancels forced mode.
- Remove entity_category: diagnostic from switch.pump so it returns to
  HA's Controls section instead of being hidden in Diagnostics.
- README: new "Migration v1.x → v2.0" section covering the blueprint +
  HA-package helpers mapping, the filtration mode option rename
  (Inactif/Hivernage/Automatique/Forcé → Off/Hiver/Courbe/Auto),
  HA re-adoption with the new api_encryption_key, and the Redox Min/Max
  → single setpoint entity rename.

Reliability / UX
- filtration: _invalid_window_alert_once script + latch global —
  surfaces pivot/pause configurations that collapse a cycle window via
  an HA persistent_notification (mirrors the _ntp_alert_once pattern).
- base: pump_uptime lambda returns 0 until g_pump_last_turn_on has been
  stamped at least once — first-flash no longer bypasses the 20-min
  warm-up guard that gates electrolyser sampling.
- filtration: forced_countdown drops the "Nh 0min" display artifact at
  exact hour boundaries.

Review artifact: .context/compound-engineering/ce-code-review/20260421-223003-d789f564/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two architecture learnings captured from the ce-code-review of PR #6.

New — antifreeze-nan-silent-failure.md
  ESPHome antifreeze binary_sensor silently stays OFF when its Dallas
  pipe_temp_raw sensor returns NaN. C++ comparisons with NaN are always
  false, so the latched hysteresis never triggers. Fix: add
  `if (std::isnan(t)) return state;` at the top of the lambda to preserve
  last known state on sensor failure (matches the repo-canonical idiom
  already used in _calcul_filtration). Generalizes to every
  sensor-gated safety binary_sensor in the firmware — documented as a
  review checklist + reusable code snippet.

Refresh — redox-asymmetric-regulation-policy.md
  Updated to reflect the second bypass discovered in PR #6: the
  select.electrolyser_mode.on_value Auto branch was calling turn_on
  immediately on low ORP, bypassing the 30-min stability gate the same
  way the deleted on_value_range below: trigger did. Root cause and
  Solution now document the second writer + its fix (commit 4358410,
  including the g_redox_stable_minutes = 0 counter reset on mode
  transition). Prevention → Review checklist rewritten to enumerate all
  five writer types explicitly (on_value_range, interval, select.on_value,
  button.on_press, api.actions) instead of the abstract "any trigger that
  writes to electrolyser" — the abstraction is exactly what masked the
  second bypass. Global names updated to the g_ prefix convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture repo-specific architecture that spans multiple files — preset/packages
composition, pump-authority ordering inside the 30s interval, the asymmetric
electrolyser regulation policy, and the ESPHome script-mode + NaN-guard gotchas
already documented under docs/solutions/architecture/ — so future agent sessions
start with load-bearing context instead of re-deriving it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gaetanars and others added 5 commits April 22, 2026 14:16
Les presets affichaient historiquement `version: "2.0.0"` (incohérent
avec les tags v0.0.1 / v0.1.0) et le README narrait une migration
v1.x → v2.0 inexistante. Ramène les 8 presets à "0.2.0" et réécrit la
section migration en v0.1 → v0.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Les 3 brainstorms du 2026-04-21 (pump authority, LAN hardening, redox
policy) et le plan LAN hardening sont entièrement livrés dans cette PR.
Les learnings réutilisables ont été capturés dans
docs/solutions/architecture/ (commit 101c126), donc ces docs d'avant-vol
n'ont plus lieu d'être.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Réintroduit le compteur de durée de session d'électrolyse (retiré en
b6ee460 comme dead code faute de reader). Cette fois le global est
exposé via un sensor template `Durée Electrolyse` et ajouté à la
dashboard, donc il sert réellement.

Sémantique :
- `g_electrolyser_active_minutes` incrémente de 1 toutes les 60 s si la
  pompe ET l'électrolyseur sont tous les deux ON (pause silencieuse
  quand la pompe coupe, sans reset).
- Reset à 0 uniquement via `switch.electrolyser.on_turn_off` — hook sur
  le switch plutôt que sur les 5 writers (select, interval, on_value_range,
  button, api), donc un seul point de vérité.
- `restore_value: True` pour qu'un reboot ne perde pas la session en
  cours tant que le switch (`RESTORE_DEFAULT_ON`) conserve son état.

Validé via `esphome config salt_full.yaml` (local 2026.4.1, CI 2026.3.3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…time

Deux améliorations liées, issues du même feedback review :

1. **Uniformité du pattern template sensor**. Le capteur
   `electrolyser_duration` utilisait `update_interval: 60s` alors que
   tous les sensors de filtration.yaml sont en `update_interval: never`
   avec `component.update` explicite. Deux timers 60 s indépendants
   (sensor vs interval d'incrémentation) dérivaient et le reset suite à
   `on_turn_off` pouvait mettre jusqu'à 60 s à s'afficher. Alignement
   sur le pattern repo.

2. **Capteur "Durée Filtration Effective"**. Le capteur existant
   `duree_filtration_journaliere` expose la durée *planifiée* (fin1-
   debut1)+(fin2-debut2), pas le temps réellement tourné. Antigel hors
   fenêtres, mode forcé, NTP drop → divergences invisibles.

   Ajout d'un compteur séparé :
   - `g_pump_effective_minutes_today` (restore_value: False, aligné sur
     les autres globals filtration qui se recalculent au boot).
   - Reset par la cron midnight existante (`0 0 0 * * *`).
   - Tick par un interval 60 s indépendant (ne parasite pas le scheduler
     30 s qui arbitre la pompe).
   - Sensor `filtration_effective_minutes` en pattern never + update.

   Dashboard : labels renommés "Planifiée (min)" / "Effective (min)"
   pour lever l'ambiguïté sans changer les entity_ids.

Validé via `esphome config salt_full.yaml`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…=True

Justification d'origine (alignement sur les autres globals False du
package) était incorrecte : ces autres globals sont False parce qu'ils
sont recalculés au boot par _calcul_filtration. Ce compteur, lui, n'est
pas recalculé — il repartait simplement à 0 à chaque reboot.

Un OTA ou une brève coupure mi-journée perdait le cumul. Avec True, on
préserve le cumul au prix d'un risque résiduel : un reboot qui traverse
minuit affiche la valeur d'hier jusqu'à la prochaine cron 00:00
(auto-heal ≤ 24 h). Les OTA / coupures brèves étant plus fréquentes
que les power-off cross-midnight sur un ESP alimenté en permanence, le
nouveau défaut est meilleur.

Aligné avec g_pump_last_turn_on (base.yaml) qui accepte le même
tradeoff pour l'état pompe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gaetanars gaetanars merged commit afddcb6 into main Apr 22, 2026
8 checks passed
@gaetanars gaetanars deleted the feat/esphome-standalone-filtration branch April 22, 2026 13:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant