fix: publish fresh live energy at poll publication #68

Merged
mat merged 1 commit from fresh-energy-publish into main 2026-07-26 07:54:57 +00:00
Owner

Corrige la cause dominante des warnings recorder state is not strictly increasing — celle que la PR #66 ne couvrait pas.

Problème (constaté en production)

Les entités *_total_energy (state_class: TOTAL_INCREASING) reculent ~8 fois par heure, déclenchant à chaque fois le WARNING recorder. La PR #66 a bien corrigé la réconciliation (saut des corrections backward ≤ 5 Wh) mais les reculs persistent : la réconciliation ne tourne qu'~1×/h (gate ENERGY_MIN_FETCH_INTERVAL_S = 55 min) et ne peut donc pas produire 8 reculs/h.

Analyse de l'historique du solaire sur 13 h (3397 points) : 108 petits reculs (81 entre 0.01–0.1 Wh, 18 entre 0.1–1, 9 entre 1–5), répartis 6 à 11 par heure, toutes les heures.

Trace live prouvant la cause (soutirage, sans aucun redémarrage) :

07:01:21.528   8674.0763   (écriture stream)
07:01:37.025   8678.2826   (le stream avance +4.2)
07:01:39.963   8674.0763   <-- RECUL -4.2064, exactement la valeur de 07:01:21
07:01:52.423   8680.3800   (le stream re-avance)

Mécanisme

  1. _fetch_device_metrics capture energy = state.live_total_wh au moment où il traite CE device.
  2. _fetch_all boucle séquentiellement sur les ~21 devices, chacun faisant son I/O réseau (~0.5–1 s), et ne renvoie devices_data qu'à la fin (~10–20 s plus tard).
  3. DataUpdateCoordinator remplace self.data d'un seul coup → l'energy du 1er device est périmé de ~20 s à la publication.
  4. Pendant ce temps le thread stream fait avancer state.live_total_wh et écrit dev["energy"] via integrate_live_energy. La publication du poll écrase cette valeur plus récente → recul → warning.
  5. Le stream re-avance au burst suivant, d'où le motif « avance / recul / re-avance ».

La magnitude corrèle avec la lenteur du poll : les polls rapides publient +0.0001 (inoffensif), les polls lents (dont celui de la réconciliation horaire, qui fait un appel API en plus) produisent les reculs de 1–5 Wh.

Correctif

Passe finale dans _fetch_all, juste avant le return, qui relit l'énergie depuis l'état live courant :

for device_id, metrics in devices_data.items():
    state = self._energy_state.get(device_id)
    if state is not None and state.live_total_wh is not None:
        metrics["energy"] = state.live_total_wh

La fenêtre de péremption passe de ~10–20 s à quelques microsecondes. La garde live_total_wh is not None préserve intégralement le chemin legacy (device dont le stream n'a pas encore pris le relais). _fetch_device_metrics n'est pas modifié — son instantané sert toujours au chemin legacy et à la réconciliation ; la correction est purement au niveau de la publication.

Tests

2 nouveaux (TDD RED→GREEN) : le premier simule l'avancée du stream pendant la boucle de fetch (via un side_effect sur l'appel client du device 2 qui fait avancer l'état du device 1) et asserte que la valeur publiée est la valeur fraîche et non l'instantané périmé — il échoue avant le correctif. Le second vérifie que le chemin sans stream n'est pas écrasé. 91 tests au total, ruff + mypy clean.

Périmètre

custom_components/comwatt/coordinator.py + tests/test_coordinator.py uniquement. Base main. Indépendante de la PR sœur save-on-stop (qui touche __init__.py).

Corrige la cause **dominante** des warnings recorder `state is not strictly increasing` — celle que la PR #66 ne couvrait pas. ## Problème (constaté en production) Les entités `*_total_energy` (`state_class: TOTAL_INCREASING`) reculent **~8 fois par heure**, déclenchant à chaque fois le WARNING recorder. La PR #66 a bien corrigé la réconciliation (saut des corrections backward ≤ 5 Wh) mais les reculs persistent : la réconciliation ne tourne qu'~1×/h (gate `ENERGY_MIN_FETCH_INTERVAL_S` = 55 min) et ne peut donc pas produire 8 reculs/h. Analyse de l'historique du solaire sur 13 h (3397 points) : **108 petits reculs** (81 entre 0.01–0.1 Wh, 18 entre 0.1–1, 9 entre 1–5), répartis 6 à 11 **par heure**, toutes les heures. Trace live prouvant la cause (soutirage, sans aucun redémarrage) : ``` 07:01:21.528 8674.0763 (écriture stream) 07:01:37.025 8678.2826 (le stream avance +4.2) 07:01:39.963 8674.0763 <-- RECUL -4.2064, exactement la valeur de 07:01:21 07:01:52.423 8680.3800 (le stream re-avance) ``` ## Mécanisme 1. `_fetch_device_metrics` capture `energy = state.live_total_wh` au moment où il traite CE device. 2. `_fetch_all` boucle **séquentiellement** sur les ~21 devices, chacun faisant son I/O réseau (~0.5–1 s), et ne renvoie `devices_data` qu'**à la fin** (~10–20 s plus tard). 3. `DataUpdateCoordinator` remplace `self.data` d'un seul coup → l'`energy` du 1er device est périmé de ~20 s à la publication. 4. Pendant ce temps le thread stream fait avancer `state.live_total_wh` et écrit `dev["energy"]` via `integrate_live_energy`. La publication du poll **écrase** cette valeur plus récente → recul → warning. 5. Le stream re-avance au burst suivant, d'où le motif « avance / recul / re-avance ». La magnitude corrèle avec la lenteur du poll : les polls rapides publient +0.0001 (inoffensif), les polls lents (dont celui de la réconciliation horaire, qui fait un appel API en plus) produisent les reculs de 1–5 Wh. ## Correctif Passe finale dans `_fetch_all`, juste avant le `return`, qui relit l'énergie depuis l'état live courant : ```python for device_id, metrics in devices_data.items(): state = self._energy_state.get(device_id) if state is not None and state.live_total_wh is not None: metrics["energy"] = state.live_total_wh ``` La fenêtre de péremption passe de ~10–20 s à quelques microsecondes. La garde `live_total_wh is not None` préserve intégralement le chemin legacy (device dont le stream n'a pas encore pris le relais). `_fetch_device_metrics` n'est pas modifié — son instantané sert toujours au chemin legacy et à la réconciliation ; la correction est purement au niveau de la publication. ## Tests 2 nouveaux (TDD RED→GREEN) : le premier simule l'avancée du stream **pendant** la boucle de fetch (via un `side_effect` sur l'appel client du device 2 qui fait avancer l'état du device 1) et asserte que la valeur publiée est la valeur **fraîche** et non l'instantané périmé — il échoue avant le correctif. Le second vérifie que le chemin sans stream n'est pas écrasé. 91 tests au total, ruff + mypy clean. ## Périmètre `custom_components/comwatt/coordinator.py` + `tests/test_coordinator.py` uniquement. Base `main`. Indépendante de la PR sœur `save-on-stop` (qui touche `__init__.py`).
🐛 fix: publish fresh live energy at poll publication
All checks were successful
Validate / lint-ruff (pull_request) Successful in 7s
Validate / test-pytest (pull_request) Successful in 3m0s
Validate / type-check-mypy (pull_request) Successful in 3m5s
Validate / lint-ruff (push) Successful in 7s
Validate / test-pytest (push) Successful in 3m0s
Validate / type-check-mypy (push) Successful in 3m6s
41371b3bbf
mat changed title from WIP: fix: publish fresh live energy at poll publication to fix: publish fresh live energy at poll publication 2026-07-26 07:54:24 +00:00
mat merged commit be6cd9382c into main 2026-07-26 07:54:57 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
mat/homeassistant-comwatt!68
No description provided.