L'agent plugin a mesuré l'inverse sur la même box : écho verbatim, "auto" compris, EnergyErrorNoError. L'exception existe déjà côté moteur — reconcileRankOrigin() accepte "auto" quand le persisté le porte déjà, écrite exprès pour ne pas rompre la neutralité Get → Set. Ce que j'avais mesuré est autre chose : entre ma lecture et mon écriture, la marque avait été consommée par une manipulation concurrente. Mon écho portait donc un "auto" que le persisté ne portait plus — un écho périmé, correctement refusé. Deux cas distincts, un seul symptôme, et c'est précisément pourquoi une écriture qui ne relit pas juste avant ne prouve rien. Le contrôle de la suppression, lui, relit avant de conclure ; ma sonde jetable, non. §10 corrigé : la ligne du tableau dit maintenant ce qu'elle mesure vraiment, et ce qui reste à mesurer (un écho inchangé sur une marque FRAÎCHE). sansRankOriginAuto() est documentée comme un contournement EN SURSIS — elle part au résultat de cette mesure, et la marque survivra alors aux gestes qui ne la concernent pas. Rien n'est retiré d'ici là. Ce qui reste vrai de mon relevé : "user" s'écrit, ne déplace pas le rang, survit aux gestes voisins. Et l'effacement de "auto" que j'ai observé est le fait de l'APP — de son nettoyage — pas d'un refus de la box. ── Deux pièges de sonde, consignés dans tools/rpc/README.md ───────────────── 1. Un refus arrive en `status: "success"`, verdict dans params.energyError. Une sonde qui ne teste que `status` annonce « accepté » sur une écriture rejetée. 2. Les identifiants ne s'écrivent pas pareil d'un appel à l'autre — le §9, documenté la VEILLE, et la sonde y est quand même tombée : elle rendait des verdicts sur une charge qu'elle ne regardait pas. La leçon n'est pas « écrire des sondes plus soigneusement » : c'est que la sonde ne partage pas les garde-fous de l'app. Quand la question porte sur ce que l'app fera, importer son modèle vaut mieux que relire le JSON ; quand elle porte sur la box, croiser la réponse RPC avec journalctl avant de conclure. Et la raison lisible d'un refus n'existe QUE dans ce journal — côté RPC il ne reste qu'un code. Silence de refus ouvert depuis le premier jour, rencontré une fois de plus, et raison d'être des miroirs clients comme validateSet. Rien de touché côté entrelacement : ça attend le moteur. 226 tests inchangés, analyze inchangé à 27. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
556 lines
30 KiB
Markdown
556 lines
30 KiB
Markdown
# ETM-PowerSync — Contrat de données UI (qui calcule quoi)
|
||
|
||
> Compagnon de `DASHBOARD_SPEC.md`. Tranche la question récurrente
|
||
> « **qui calcule les valeurs affichées ?** » pour chaque écran.
|
||
> Rev. 7 — ajout du **§10** : une valeur interdite à l'écriture doit dire ce qu'elle fait
|
||
> d'un écho inchangé, sinon l'écriture verbatim devient impossible. Et le motif derrière :
|
||
> trois fois en deux jours, le chemin exposé n'était pas celui qu'on regardait.
|
||
> Rev. 6 — ajout du **§9** : deux réponses de la même box peuvent employer deux
|
||
> conventions sans qu'aucun signal ne le dise, et l'erreur est toujours muette.
|
||
> Deux occurrences en une semaine (clés de log sans préfixe `currentPower`,
|
||
> identifiants de borne avec/sans accolades).
|
||
> Rev. 5 — sonde `.75` : réserve décision-2 **levée** (cumuls séparés) ; ajout de
|
||
> 3 garde-fous : 2 conventions de signe (§7.1), buckets par Δ-de-cumuls (§7.3),
|
||
> gestion reset compteurs (§2.1/§8.1) ; RP calquées sur le template nymea, Influx
|
||
> v1.6.7 confirmée.
|
||
> Rev. 4 — §8.3 corrigé : **réutiliser l'InfluxDB nymea** (pas SQLite), DB ETM
|
||
> dédié + rollups natifs (retention policies) + checkpoint local pour le contrôle
|
||
> vivant. Alerte : tag Docker `latest` → InfluxDB 3 Core (72 h de rétention).
|
||
> Rev. 3 — ajout §7 (Héos data inputs & metering map) + §8 (persistance &
|
||
> historique : mesures brutes stockées / ratios dérivés / plan stocké).
|
||
> Rev. 2 — §2 corrigé après sonde terrain sur `.75` (nymead 1.15.2) :
|
||
> `GetPowerBalanceLogs` n'expose **aucun ratio**, seulement
|
||
> production/consumption/acquisition/storage + cumuls. La source canonique
|
||
> des ratios est donc le **state du plugin energymanager**, pas l'app,
|
||
> pas l'Energy experience nymea.
|
||
|
||
---
|
||
|
||
## 0. Principe directeur
|
||
|
||
**L'app Flutter est un moteur de rendu, pas un moteur de calcul.**
|
||
|
||
Source de vérité unique, comme `INTERFACE_etmvariableload.md` côté plugins.
|
||
Si l'app refait les maths de son côté, elle finit par **contredire l'arbitre**
|
||
(cf. Invariant 3 — charges pilotées au Grid mais hors surplus d'arbitrage ;
|
||
cf. phantom EV reservation — commandé vs mesuré). Un seul endroit calcule.
|
||
|
||
### Frontière
|
||
|
||
| Catégorie | Où ça se calcule | Raison |
|
||
|---|---|---|
|
||
| Sens / couleur des flèches du flow | **App** (dérivé du signe d'un état) | trivial, non ambigu, instantané |
|
||
| Surplus d'arbitrage | **energymanager plugin** | respecte l'Invariant 3 |
|
||
| Auto-conso / autonomie / totaux jour | **energymanager plugin (state)** | nymea 1.15.2 ne logge pas les ratios (sonde `.75`) |
|
||
| Décisions / raisons en cours | **energymanager** (`loadActions[]`) | l'arbitre est seul à savoir *pourquoi* |
|
||
| Plan de la journée (futur) | **Héos** (etm-powersync-eos) | seul à avoir forecast + MILP |
|
||
| Plan de la journée (passé) | **nymea energy logs** | historique réalisé |
|
||
|
||
### Couches (rappel)
|
||
|
||
1. **nymea things** → états instantanés bruts (P par compteur) + energy logs (historique).
|
||
2. **energymanager plugin** → surplus (Invariant 3), décisions+raisons, ratios live.
|
||
3. **etm-powersync-eos (Héos)** → forecast + plan + raisonnement tarifaire.
|
||
4. **App Flutter** → rend 1+2+3. Ne dérive QUE le sens des flèches.
|
||
|
||
---
|
||
|
||
## 1. Flux énergétique (flow-card)
|
||
|
||
### 1.1 Direction & couleur — règles figées
|
||
|
||
| Nœud | Comportement | Sens des particules | Couleur |
|
||
|---|---|---|---|
|
||
| **Solaire (PV)** | producteur pur | toujours PV → hub | jaune `#FEC113`, jamais inversé |
|
||
| **Maison** | consommateur pur | toujours hub → Maison | bleu `#31A3DD`, jamais inversé |
|
||
| **Réseau** | bidirectionnel | import = Réseau→hub / export = hub→Réseau | import **rouge** (coût) · export **vert atténué** (revente faible) |
|
||
| **Batterie** | bidirectionnel | charge = hub→Bat / décharge = Bat→hub | décharge **vert** (aide) · charge **neutre/bleu** |
|
||
|
||
> La convention de signe doit être **explicite et documentée** (un seul endroit).
|
||
> Sur la capture actuelle : Réseau en rouge vers le centre = import 2.4 kW ✓.
|
||
> Vérifier que le sens batterie reflète bien charge vs décharge.
|
||
|
||
### 1.2 Héos au centre — décision à prendre
|
||
|
||
Physiquement **faux** : Héos est le contrôleur, pas un nœud électrique.
|
||
Le vrai point de couplage AC est au centre, Héos est à côté.
|
||
|
||
- **Option A** — garder Héos au centre comme « le cerveau ». Les lignes = la
|
||
conscience/le contrôle, pas le câble. Métaphore assumée. Fort en branding.
|
||
Acceptable car la carte parle *de pilotage*.
|
||
- **Option B** — nœud central = point de couplage (busbar AC neutre), Héos en
|
||
badge/overlay. Physiquement correct, plus sobre.
|
||
|
||
→ **Reco** : A est défendable tant que l'animation respecte 1.1. Ne pas laisser
|
||
les particules « traverser » Héos dans un sens physiquement impossible.
|
||
|
||
### 1.3 Mono vs multi-onduleur
|
||
|
||
- Le **nœud Solaire du diagramme affiche TOUJOURS l'agrégat** (somme). Ne jamais
|
||
multiplier les nœuds dans le schéma — ça le casse visuellement.
|
||
- Le détail (tap sur `+`) gère la multiplicité :
|
||
- 1 onduleur → prod + strings si dispo.
|
||
- N onduleurs → agrégat en tête + liste par onduleur.
|
||
|
||
### 1.4 Détails Maison & compteur non mesuré (ex. PAC)
|
||
|
||
- Nœud Maison = consommation **totale**, dérivée :
|
||
`Maison = PV + import_grid + décharge_bat − export_grid − charge_bat`.
|
||
- Le détail ne montre des sous-postes **que pour les compteurs qui existent**.
|
||
- **Charge connue mais non mesurée (PAC sans compteur dédié)** :
|
||
- ne **jamais** afficher un faux 0 ni une estimation inventée (anti-oversell) ;
|
||
- soit on l'omet, soit on affiche « non mesuré » explicitement.
|
||
- Décision implicite à acter : la décomposition par appareil **nécessite un
|
||
compteur par appareil**. Pas de compteur PAC = pas de ligne PAC.
|
||
|
||
---
|
||
|
||
## 2. Cartes Auto-conso / Autonomie
|
||
|
||
- **Agrégats sur une période** → **jamais** calculés par l'app (sinon dérive
|
||
selon le rythme de polling, et désaccord avec le bilan/logs).
|
||
- **Réalité 1.15.2 (sonde `.75`)** : `GetPowerBalanceLogs` n'expose **aucun
|
||
ratio ni décomposition par bande** — seulement production / consumption /
|
||
acquisition / storage + **cumuls**. La prémisse « ratios déjà calculés
|
||
backend » est donc fausse littéralement.
|
||
- **Source canonique = state du plugin energymanager** (une seule source).
|
||
Pas l'app, pas l'Energy experience. Justification : ce n'est pas un pis-aller,
|
||
c'est le **seul composant capable de calculer juste** (voir ci-dessous).
|
||
|
||
### 2.1 Formules déterministes (depuis cumuls, zéro drift de polling)
|
||
|
||
```
|
||
autonomie = (consommation − import) / consommation
|
||
autoconsommation = (production − export) / production
|
||
```
|
||
|
||
- **autonomie** : `import` = Δ `totalAcquisition`. ✅
|
||
- **autoconsommation** : `export` = Δ `totalReturn`. ✅
|
||
**RÉSERVE LEVÉE (sonde `.75`)** : le réseau *instantané* est net-signé (un seul
|
||
champ `acquisition`), MAIS les **cumuls `totalAcquisition` / `totalReturn` sont
|
||
séparés**. Donc les deux ratios sont dérivables des logs par **deltas de cumuls**
|
||
— pas besoin que le plugin intègre import/export pour l'historique.
|
||
- ⚠️ **Compteurs = monotones requis.** Δ d'un cumul n'est valide que si le compteur
|
||
ne reset jamais. Sur reset (restart nymead, re-add thing, rollover) → Δ < 0 :
|
||
détecter (`Δ < 0` ⇒ reset, reseed sur la nouvelle valeur, ne pas créer un bucket
|
||
négatif). À gérer côté plugin **et** dans les CQ de rollup.
|
||
- Ces formules sont **correctes avec batterie** : import/export au compteur
|
||
réseau nettent déjà les flux batterie ; PV→batterie→maison compte comme
|
||
autoconsommé (pas exporté). Pas de correction round-trip.
|
||
|
||
### 2.2 Bande autoconso instantanée (graphe Puissances)
|
||
|
||
- Même propriétaire : `selfConsumptionPower = max(production − max(export,0), 0)`.
|
||
- À exposer en **state plugin**, pas dérivé app — sinon re-bug de signe à chaque
|
||
refonte (le `.abs()` interim n'était qu'un pansement sur un calcul app-side).
|
||
|
||
---
|
||
|
||
## 3. Carte décisions — AVEC vs SANS Héos (frontière open-core)
|
||
|
||
La carte actuelle parle **au futur** : « coût réseau ce soir 0,22 € »,
|
||
« objectif 80 % pour 18h ». Ça suppose un **forecast + un plan** = Héos.
|
||
|
||
### 3.1 Avec Héos (tier Predict/AI)
|
||
|
||
Inchangé : raisonnement tarifaire, objectifs horaires, plan. Texte = sortie Héos.
|
||
|
||
### 3.2 Sans Héos (tier Community, rule-based)
|
||
|
||
Le rule-based **n'a ni forecast ni plan**. Il ne peut donc pas tenir ce discours.
|
||
|
||
- En-tête : « **Pilotage automatique** » (pas « Héos pilote »).
|
||
- Décisions exprimées **au présent, réactives** :
|
||
- « Surplus solaire 2.0 kW → chauffe-eau activé »
|
||
- « Pas de surplus → voiture en pause »
|
||
- **Zéro promesse au futur** (pas de « ce soir », pas de « objectif 18h »).
|
||
Honnêteté : ne pas faire croire à une intelligence prédictive absente.
|
||
- Source du texte : `loadActions[]` de l'energymanager (la *raison* additive
|
||
qu'on a déjà figée), pas une reconstruction côté app.
|
||
|
||
### 3.3 Conséquence sur « Plan de la journée »
|
||
|
||
Voir §4 : sans Héos, la partie future (hachurée) disparaît. Surface d'upsell
|
||
naturelle : « Activez Héos pour planifier la journée ».
|
||
|
||
---
|
||
|
||
## 4. Plan de la journée
|
||
|
||
- **Passé (barres pleines)** = énergie réalisée → **nymea energy logs**.
|
||
- **Futur (barres hachurées)** = **Héos** (etm-powersync-eos → socket →
|
||
energymanager → state/endpoint exposé). L'app ne projette **rien** elle-même.
|
||
- Sans Héos : n'afficher **que le passé**. Pas de projection bricolée côté app.
|
||
- Légende (Maison / ECS / VE / Batterie / Réseau / Batt→maison) = catégories
|
||
de l'allocation Héos ; mêmes clés que le plan, pas de remapping côté app.
|
||
|
||
---
|
||
|
||
## 5. Config — Things (placeholder actuel)
|
||
|
||
L'app n'est **pas** un fork de nymea-app → il faut réimplémenter les RPC
|
||
(`Integrations.*` : GetThings, GetThingClasses, GetVendors, DiscoverThings,
|
||
AddThing/PairThing/ConfirmPairing, EditThing, RemoveThing). C'est gros, et
|
||
nymea-app le fait déjà bien. **Décision de périmètre à acter explicitement** :
|
||
|
||
- **Option MVP** — liste read-only des things + actions simples (renommer,
|
||
activer/désactiver, supprimer). 80 % de la complexité du wizard est dans
|
||
l'ajout avec discovery/pairing/paramètres — on l'évite.
|
||
- **Option ciblée** — réimplémenter le wizard **seulement pour TES classes**
|
||
(eastron, abbterra, v2c… dont tu maîtrises les params).
|
||
- **Option hand-off** — la config avancée reste dans nymea-app (installateur),
|
||
l'app ETM se concentre sur l'experience énergie (client final).
|
||
|
||
→ Question vraie : le client final a-t-il **besoin** d'ajouter des things, ou
|
||
c'est un acte installateur (toi/Mathieu) ? Si installateur-only, ne pas
|
||
construire un demi-wizard.
|
||
|
||
---
|
||
|
||
## 6. Config — Protocoles → ModbusRTU master
|
||
|
||
Surface **finie et bien définie**, dont **tes propres plugins compteurs
|
||
dépendent** (SDM, abbterra) → vaut le coup en natif.
|
||
|
||
- `ModbusRtu.GetSerialPorts` → lister les ports.
|
||
- `GetModbusRtuMasters` / `AddModbusRtuMaster` / `Reconfigure…` / `Remove…`.
|
||
- Params : serialPort, baudrate, parity, stopBits, dataBits.
|
||
|
||
→ Petit, fermé, et bloquant pour tes meters. Le construire avant le wizard Things.
|
||
|
||
---
|
||
|
||
## 7. Héos — data inputs & metering map
|
||
|
||
### 7.1 Règle de granularité
|
||
|
||
**nymea ne stocke que ce qui est compté.** Un consommateur n'a de série de
|
||
puissance que s'il est un *thing avec un state `currentPower`* (interfaces
|
||
`smartmeter`/`energymeter`/consumer/producer, `logged: true`).
|
||
→ **La granularité d'optimisation Héos = la granularité de comptage.**
|
||
|
||
⚠️ **DEUX conventions de signe coexistent — ne pas les confondre** (piège « fix »
|
||
d'un faux bug) :
|
||
1. **Thing-level `currentPower`** : point de vue **consommateur** → **négatif pour
|
||
un producteur** (le PV a `currentPower` < 0). C'est la cause du bug autoconso /
|
||
`.abs()`.
|
||
2. **Power balance agrégé** (`production` / `consumption` / `acquisition`) :
|
||
`production` **positif**, `consumption` positif, `acquisition` **net signé**
|
||
(+ import / − export). (Sonde `.75` : `acquisition = -231`, et un
|
||
`currentPowerProduction` **positif** au niveau balance — normal, ce n'est PAS
|
||
le `currentPower` thing-level.)
|
||
|
||
Le signe est **canonique des deux côtés** — l'app ne le devine jamais ; elle ne
|
||
dérive que le *sens* d'un flux à partir du signe (§1.1), rien d'autre.
|
||
|
||
### 7.2 Carte de comptage (état du parc)
|
||
|
||
| Consommateur | Thing ? | `currentPower` ? | Héos optimise finement ? |
|
||
|---|---|---|---|
|
||
| Réseau (GRID) | ✅ | ✅ | référence (root meter) |
|
||
| PV / onduleur | ✅ | ✅ (négatif) | oui, **par onduleur** |
|
||
| Batterie | ✅ | ✅ | oui |
|
||
| VE (wallbox) | ✅ | ✅ | oui |
|
||
| ECS (relais SDM) | ✅ | ✅ ou déduit du niveau | oui |
|
||
| **PAC** | SG-Ready | ❌ `sgReadyMode` seul | **non — pilotable, pas mesurable** |
|
||
| **Clim (Zenkeo/Tuya)** | bridge | ⚠️ seulement si DP Tuya puissance | partiel |
|
||
| Maison (base) | dérivé | résidu = total − charges pilotées | `Maison_base` |
|
||
|
||
- **Trou PAC** : SG-Ready n'expose que `sgReadyMode` (Off/Low/Standard/High),
|
||
aucune puissance. Sans compteur dédié, Héos **pilote** la PAC mais ne la
|
||
**mesure** pas ; sa conso reste noyée dans `Maison_base`.
|
||
- **Clim** : seulement si `clim_bridge.py` remonte un state puissance.
|
||
- **Production par panneau de toit : non.** Par onduleur oui ; par string
|
||
seulement si le plugin expose les states MPPT. Ne pas concevoir Héos en
|
||
supposant une granularité panneau.
|
||
- **Conséquence** : toute optim PAC/clim fine est une décision **matérielle**
|
||
(poser un compteur / exposer un DP), pas logicielle.
|
||
|
||
### 7.3 Alimentation de Héos — Route B (tranché)
|
||
|
||
- **States = source live**, lus en in-process par le plugin.
|
||
- **energymanager = série historique canonique** par consommateur/générateur,
|
||
échantillonnée à la résolution du MPC (buckets 15 min), Invariant-3-correcte.
|
||
- **logs nymea = filet / contrôle croisé**, pas l'entrée primaire de Héos.
|
||
- **Construire les buckets par Δ de compteurs, pas par intégration du signal live**
|
||
(sonde `.75` : les cumuls sont séparés) :
|
||
- grid import/export du bucket = Δ `totalAcquisition` / Δ `totalReturn` ;
|
||
- conso par appareil = Δ `thing.totalEnergyConsumed` ;
|
||
- exact, pas de drift d'intégration. L'intégration du signal net-signé ne sert
|
||
qu'au temps réel sous le bucket (affichage), pas à la série stockée.
|
||
- ⚠️ même gestion de reset qu'en §2.1/§8.1 (Δ < 0 ⇒ reset).
|
||
- Justification Route B : la Route A (logs nymea bruts) impose le downsampling/
|
||
rétention de nymea sans contrôle du schéma ; le plugin reste seul à figer la
|
||
décomposition Invariant-3 dans la série curée.
|
||
|
||
---
|
||
|
||
## 8. Persistance & historique
|
||
|
||
> Deux classes de données aux cycles de vie **opposés**. C'est la règle centrale.
|
||
|
||
### 8.1 Mesures — stocker le brut, dériver les ratios
|
||
|
||
- On stocke le **brut intégrable** par bucket : production, consommation,
|
||
import, export (séparés !), + par charge pilotée, + charge/décharge batterie.
|
||
→ obtenu par **Δ de cumuls** (`totalProduction`, `totalReturn`,
|
||
`totalConsumption`, `totalAcquisition`, `thing.totalEnergyConsumed`), pas par
|
||
intégration du signal live (§7.3). **Gérer le reset** (Δ < 0 ⇒ reseed).
|
||
- Les **ratios ne sont JAMAIS stockés** comme donnée primaire — vues dérivées :
|
||
|
||
```
|
||
autonomie = (Σ conso − Σ import) / Σ conso
|
||
autoconsommation = (Σ prod − Σ export) / Σ prod
|
||
```
|
||
|
||
→ recalculables à la lecture pour **n'importe quelle période** (jour / semaine
|
||
/ mois / plage custom). Stocker des ratios figés = verrouillage de granularité.
|
||
|
||
### 8.2 Plan — stocker explicitement (non recalculable)
|
||
|
||
- Un **ratio se reconstruit à l'infini** depuis le brut.
|
||
Un **plan ne se recalcule jamais après coup** : il dépendait des prévisions
|
||
PV/conso/tarif **à T0** et de la solution MILP de cet instant.
|
||
→ **S'il n'est pas persisté au moment où il est produit, il est perdu.**
|
||
- Schéma : `(produced_at, bucket_start, asset, planned_value [, inputs_hash])`.
|
||
→ stockable dans Influx tagué par `plan_run_id` ; le plan **en cours
|
||
d'exécution** est en plus checkpointé localement (§8.3).
|
||
- Cadence limitée : ne pas stocker chaque tick MPC. Garder un **plan-of-record**
|
||
(ex. 1/h ou snapshot début+fin de journée), pas chaque itération.
|
||
- Débloque **plan vs réalisé** : overlay prévu/réalisé → confiance client +
|
||
matière première du federated-learning-lite (erreur de prévision).
|
||
|
||
### 8.3 Store & rétention (réalité embarquée)
|
||
|
||
**Réutiliser l'InfluxDB déjà présente** (nymea s'en sert). L'argument « service en
|
||
plus » ne tient pas : le coût est déjà payé. Influx est faite pour les séries ;
|
||
ses **retention policies + downsampling (CQ 1.x / tasks 2.x)** font les rollups
|
||
**nativement** → moins de code qu'un SQLite hand-rollé. Et configurer la rétention
|
||
**corrige au passage le « shard accumulation »** qui faisait crash-looper l'instance
|
||
(les shards s'empilaient faute de purge).
|
||
|
||
- **DB / bucket ETM dédié** — pas dans la base `nymeatest` de nymea. Isole ta
|
||
donnée des upgrades/migrations nymea et te donne ta propre rétention.
|
||
- **Calquer les paliers sur le template RP nymea existant** (sonde `.75`) plutôt
|
||
que d'inventer : `live 24h · minutes 7j · hours 3 ans · days 20 ans`
|
||
(+ `discrete 1 an`). Cohérence opérationnelle, CQ alignées.
|
||
- ⚠️ **Ne pas écrire dans `autogen` (DEFAULT, rétention ∞).** C'est le vrai risque
|
||
de croissance (pas un shard runaway : `nymeatest` n'a que 2 shards aujourd'hui).
|
||
Router explicitement les writes ETM vers les RP tiers.
|
||
- ⚠️ **Version Influx** : `.75` est en **v1.6.7** ✓ (pas 3 Core, pas de piège
|
||
72 h). Le tag Docker `latest` pointe désormais vers **InfluxDB 3 Core**
|
||
(rétention 72 h + limite 5 DB en OSS) → **pinner**, ne jamais laisser un upgrade
|
||
auto basculer.
|
||
- **Checkpoint local minuscule** (fichier / petit SQLite) pour l'**état de contrôle
|
||
vivant** (plan en cours d'exécution, dernier setpoint). Si Influx tombe, Héos ne
|
||
doit **ni hard-fail ni perdre le plan exécuté** — dégradé propre, cohérent avec
|
||
le L2 watchdog. ***Logging store down ≠ arbitrage down.***
|
||
- Distinct du log engine nymea (filet brut générique) : la série dans le DB ETM est
|
||
la **série curée** (Invariant-3, import/export scindés) dont dérivent Héos *et*
|
||
l'historique client.
|
||
|
||
### 8.4 Read model de l'historique client
|
||
|
||
```
|
||
période choisie
|
||
→ agrège les mesures au palier de rollup correspondant
|
||
→ dérive autoconso / autonomie (§8.1)
|
||
→ overlay plan-of-record (§8.2) si dispo → vue « prévu vs réalisé »
|
||
```
|
||
|
||
Même donnée parcourue dans les deux sens du temps : passé = mesures réalisées,
|
||
futur = plan Héos. La metering map §7.2 est le socle commun (entrée Héos + bandes
|
||
du « Plan de la journée »).
|
||
|
||
---
|
||
|
||
## 9. Deux réponses de la même box, deux conventions — et aucun signal
|
||
|
||
> **Motif, pas anecdote.** Deux appels JSON-RPC de la même box, portant la même grandeur ou
|
||
> le même objet, n'emploient pas forcément la même convention. Aucun schéma, aucune erreur,
|
||
> aucun journal ne le dit : **la divergence est toujours muette**, et elle se manifeste par
|
||
> un écran vide, un zéro, ou un « non lu » parfaitement crédible. Deux occurrences en une
|
||
> semaine, chacune découverte en croisant deux réponses.
|
||
|
||
### Les occurrences connues
|
||
|
||
| Croisement | Convention A | Convention B | Symptôme si on les mélange |
|
||
|---|---|---|---|
|
||
| `Energy.GetPowerBalance` × `Energy.GetPowerBalanceLogs` | `currentPowerProduction`, `currentPowerConsumption`, … | `production`, `consumption`, `acquisition`, `storage` — **sans préfixe** | tout tombe sur le défaut `0` : l'historique paraît vide alors que la base porte 7 960 échantillons |
|
||
| `NymeaEnergy.GetChargingInfos` × `NymeaEnergy.GetLoadConfig` | `evChargerId` **sans** accolades — `4d1adafd-…` | id de charge **avec** accolades — `{4d1adafd-…}` | aucune correspondance : l'écran affiche « mode non lu » sur une borne parfaitement lue, et la note « borne hors arbitrage » ne sort jamais |
|
||
| *(famille voisine, même silence)* `currentPower*` en **W** × `total*` en **kWh** | watts | kilowattheures | facteur 1000 : une journée de 78 kWh s'affiche « 78 Wh » |
|
||
|
||
### La règle
|
||
|
||
**Ne jamais rapprocher deux réponses RPC sur une clé ou un identifiant sans l'avoir
|
||
normalisé — et écrire la normalisation à un seul endroit, nommé.** Dans cette app :
|
||
`NymeaService.chargingModeOf()` pour les identifiants, le bloc de clés de
|
||
`fetchPowerBalanceLogs()` pour les noms de champs. Une comparaison brute recopiée dans un
|
||
troisième écran rouvrira le même trou.
|
||
|
||
### Comment on le trouve — et pourquoi il faut le chercher exprès
|
||
|
||
Le symptôme n'est **jamais** une exception : c'est une valeur neutre plausible. Un test
|
||
unitaire qui construit ses deux côtés avec la même convention passe aussi. Trois gestes,
|
||
dans cet ordre :
|
||
|
||
1. **Sonder les deux appels côte à côte** et comparer les charges utiles brutes, pas les
|
||
modèles Dart — `dart tools/rpc/probe.dart 192.168.1.75 <Méthode>` pour chacun.
|
||
2. **Éprouver le rapprochement lui-même**, sur les deux formes réelles. Les tests d'accolades
|
||
(`test/merged_config_screen_test.dart`) sont écrits exactement pour ça, avec les deux
|
||
graphies de la même borne.
|
||
3. **Se méfier d'un vide qui ne coûte rien à expliquer.** « Aucune donnée sur cette
|
||
période », « mode non lu », « 0 W » : quand une lecture rend un neutre sur une
|
||
installation qu'on sait vivante, la première hypothèse est le croisement, pas la box.
|
||
|
||
### Ce que ça implique pour l'affichage
|
||
|
||
Une valeur neutre issue d'un croisement raté est **indiscernable** d'une vraie absence.
|
||
C'est la raison de fond de la règle « ne jamais afficher zéro pour une absence » qui
|
||
traverse `EvChargerLive`, `DailyBalance` et `LoadMechanism` : dire « non lu » laisse une
|
||
chance de découvrir le bug ; afficher `0` l'enterre.
|
||
|
||
---
|
||
|
||
## 10. Une valeur interdite à l'écriture doit dire ce qu'elle fait d'un ÉCHO inchangé
|
||
|
||
> **Motif, comme le §9 — et il vient de le compléter.** Le §9 dit que deux réponses de la
|
||
> même box peuvent employer deux conventions sans le signaler. Celui-ci dit que deux
|
||
> **règles** posées séparément, chacune juste, peuvent se contredire au point de rendre une
|
||
> écriture impossible. Le symptôme est le même : muet.
|
||
|
||
### Le cas qui l'a produit
|
||
|
||
Deux règles, écrites à des moments différents, chacune correcte :
|
||
|
||
| Règle | Où | Pourquoi elle est bonne |
|
||
|---|---|---|
|
||
| L'app écrit **verbatim** — elle recopie ce que la box lui a rendu | `payloadWithoutLoad`, `flattenToPayload` | `SetLoadConfig` remplace tout l'ensemble : reconstruire depuis le modèle typé perdrait, en silence et pour toutes les charges, un champ qu'une box plus récente porterait |
|
||
| `rankOrigin: "auto"` est **refusé** à tout client | `isValid()` côté moteur | empêche un écran de fabriquer une affirmation sur un geste du moteur |
|
||
|
||
Leur produit : recopier verbatim une entrée marquée `"auto"`, **c'est écrire `"auto"`**. Donc
|
||
un simple **renommage** serait rejeté en bloc dès qu'une installation porte une borne
|
||
auto-provisionnée — motif invisible, le refus ne franchissant pas la frontière RPC. Le banc
|
||
ne pouvait pas le produire : aucune borne n'y a été détectée depuis `+etm27`.
|
||
|
||
### La règle, et pourquoi elle porte sur le CHANGEMENT
|
||
|
||
**Un interdit d'écriture doit se prononcer sur l'écho inchangé** — même id, même valeur,
|
||
même contexte. Renvoyer à l'identique ce que le moteur a écrit n'affirme rien de neuf : la
|
||
règle doit porter sur le **changement**, pas sur la **présence**. C'est exactement la
|
||
distinction que le moteur applique déjà à l'effacement de `rankOrigin` — il porte sur « un
|
||
rang différent pour cet id », pas sur « une écriture reçue » — à un cran de plus.
|
||
|
||
L'alternative rend la marque **fragile par construction** : l'app doit alors la nettoyer sur
|
||
toutes les charges à chaque écriture, et le badge s'éteint sur une borne que personne n'a
|
||
classée, au premier renommage d'une autre charge. **Une information qui s'efface au premier
|
||
geste voisin ne vaut pas d'être publiée.**
|
||
|
||
### Constaté sur machine le 2026-08-29, et la forme du refus
|
||
|
||
Une seconde wallbox simulée appairée après `+etm27` a produit les trois cas d'un coup :
|
||
`rankOrigin: "auto"`, rang **5** (insertion en queue), badge sur elle seule.
|
||
|
||
| Écriture | Réponse de la box | État après |
|
||
|---|---|---|
|
||
| renommage, `"auto"` renvoyé **alors que le persisté ne le portait plus** | `status: "success"` + `energyError: "EnergyErrorInvalidParameter"` | **inchangé** |
|
||
| même renommage, `"auto"` → `""` (ce que fait l'app) | `EnergyErrorNoError` | **la marque est effacée** |
|
||
| `"user"` sur la seule borne visée | `EnergyErrorNoError` | rang inchangé, seul ce champ change |
|
||
| deux renommages voisins ensuite | `EnergyErrorNoError` | **`"user"` survit** |
|
||
|
||
> ⚠️ **La première ligne ne dit PAS ce que j'ai d'abord conclu.** J'y avais lu « l'écho
|
||
> verbatim est refusé ». C'est faux : le moteur porte une exception écrite exprès
|
||
> (`reconcileRankOrigin()`) qui **accepte `"auto"` quand le persisté le porte déjà** —
|
||
> précisément pour ne pas rompre la neutralité `Get` → `Set`. Entre ma lecture et mon
|
||
> écriture, la marque avait été consommée par une manipulation concurrente : mon écho était
|
||
> donc **périmé**, et son refus est le bon comportement. Deux cas distincts, un seul
|
||
> symptôme — et c'est exactement pourquoi une mesure sans relecture immédiate ne prouve
|
||
> rien (cf. le contrôle de la suppression, qui relit avant de conclure).
|
||
>
|
||
> **Ce qui reste à mesurer** : un écho *inchangé* sur une marque *fraîche*. Tant que ce
|
||
> n'est pas fait, `sansRankOriginAuto()` reste — et le jour où l'écho passe, elle part et la
|
||
> marque survit aux gestes qui ne la concernent pas.
|
||
|
||
Le reste tient : `"user"` s'écrit, ne déplace pas le rang, et **survit** aux gestes voisins.
|
||
L'effacement observé de `"auto"` est, lui, **le fait de l'app** — de son nettoyage, pas d'un
|
||
refus de la box.
|
||
|
||
> ⚠️ **Un refus arrive en `status: "success"`.** Le verdict est dans `params.energyError`,
|
||
> et un contrôle sur `status` seul le prend pour un succès. Le service de l'app teste bien
|
||
> les deux (`setLoadConfig`) — mais une sonde jetable écrite le jour même est tombée dans le
|
||
> piège et a annoncé « ACCEPTÉ » sur une écriture que la box venait de rejeter. La raison
|
||
> lisible, elle, ne sort **que** dans le journal de la box : *« la charge "Simulated wallbox
|
||
> 2" prétend un rankOrigin "auto" que le moteur n'a pas posé »*. Côté RPC, il ne reste que
|
||
> `EnergyErrorInvalidParameter`.
|
||
|
||
### Ce que ça impose à l'app, aujourd'hui
|
||
|
||
Le nettoyage vit à un **point de sortie unique** (`NymeaService.setLoadConfig`), jamais sur
|
||
chaque chemin : un chemin ajouté demain hériterait sinon du refus sans que rien ne
|
||
l'annonce. C'est un contournement assumé, avec son coût écrit à côté — pas une solution.
|
||
|
||
### Le contrôle à faire, une fois par règle d'écriture
|
||
|
||
Pour chaque valeur qu'une box refuse en écriture, poser la question **avant** de la
|
||
rencontrer :
|
||
|
||
1. l'app peut-elle la **lire** ? Si oui, elle finira par la **renvoyer**, puisqu'elle écrit
|
||
verbatim ;
|
||
2. l'écho inchangé est-il toléré ? Si la réponse n'est pas écrite, **c'est un trou**, pas un
|
||
détail d'implémentation ;
|
||
3. si l'écho est refusé, que devient l'information au premier geste voisin ? Si elle
|
||
disparaît, la publier est un leurre.
|
||
|
||
### Et le motif derrière le motif
|
||
|
||
**Trois fois en deux jours, le chemin exposé n'était pas celui qu'on regardait** :
|
||
|
||
- l'écriture du mode de recharge visait « la première borne configurée » — trouvé en
|
||
**déplaçant** les boutons, pas en les testant ;
|
||
- le refus sur rangs doublés menaçait la **suppression** (qui réécrit verbatim), pas le
|
||
glisser-déposer qu'on surveillait ;
|
||
- le refus de `"auto"` menace le **renommage**, pas la gestion des rangs.
|
||
|
||
Le point commun n'est pas la malchance : c'est que ces chemins **transportent** des données
|
||
qu'ils ne regardent pas. Un chemin qui recopie est exposé à tout ce que la charge utile
|
||
contient, y compris ce qui a été ajouté après lui.
|
||
|
||
---
|
||
|
||
## Récap des décisions
|
||
|
||
1. Héos au centre du flow : **Option A — métaphore « cerveau » assumée**
|
||
(condition : animation respecte les sens figés §1.1, jamais de particule à
|
||
contre-sens à travers Héos). ✅ tranché.
|
||
2. Source des ratios : **state du plugin energymanager** (ni app, ni Energy
|
||
experience). Dérivation depuis cumuls via §2.1. **Réserve levée** (sonde `.75` :
|
||
`totalAcquisition`/`totalReturn` séparés) — dérivable des logs, le plugin n'a
|
||
pas à intégrer pour l'historique. ✅ tranché.
|
||
3. Périmètre Things app : **hand-off nymea-app + MVP read-only client** ;
|
||
**ModbusRTU master en premier** (§6, bloquant pour SDM/abbterra). Wizard
|
||
ciblé seulement si vraiment nécessaire. ✅ tranché.
|
||
4. Carte sans Héos : wording **présent/réactif** + masquage du futur, texte
|
||
issu de `loadActions[]`. ✅ tranché.
|
||
5. Alimentation Héos : **Route B** — states (live) + série canonique
|
||
energymanager (15 min, Invariant-3), logs nymea en filet. ✅ tranché.
|
||
6. Persistance : **mesures brutes stockées + ratios dérivés** ; **plan stocké
|
||
explicitement** (non recalculable) ; **réutiliser l'InfluxDB existante** (DB ETM
|
||
dédié, rollups via retention policies) + **checkpoint local** pour l'état de
|
||
contrôle vivant. ⚠️ pinner Influx en **1.x/2.x** (pas 3 Core / 72 h). ✅ tranché.
|
||
|
||
### Vérifié sur sonde (`.75`)
|
||
|
||
- ✅ **PowerBalance** : instantané net-signé (`acquisition`), cumuls
|
||
`totalAcquisition`/`totalReturn` **séparés** → ratios dérivables par Δ (§2.1).
|
||
- ✅ **Influx v1.6.7** (pas 3 Core). DB nymea = `nymeatest`, RP existantes
|
||
(`live/minutes/hours/days/discrete`), DEFAULT = `autogen` ∞ (le vrai risque).
|
||
|
||
### Reste ouvert (vrai)
|
||
|
||
- **Confirmer les signes sur box réelle** (`.120`) — `.75` est statique/factice
|
||
(totaux à 0). L'archi plugin=source rend l'app sign-agnostic, mais valider.
|
||
- **Carte de comptage §7.2** : décider produit par produit quels consommateurs
|
||
non mesurés (PAC, clim) justifient un compteur matériel pour une optim fine.
|
||
- **Gestion reset des compteurs** (§2.1/§7.3/§8.1) : implémenter la détection
|
||
Δ < 0 côté plugin **et** dans les CQ de rollup.
|