Seconde wallbox simulée appairée sur .75 après +etm27. L'entrée n'apparaît pas instantanément : ma première lecture montrait 4 charges, la suivante 5. Puis tout se confirme — rankOrigin "auto", rang 5 (insertion en queue), badge sur elle seule, alerte d'ex æquo muette. Le coût du §10 est constaté, et il est exactement celui annoncé : écho de "auto" par un client → EnergyErrorInvalidParameter, config INCHANGÉE "auto" → "" (ce que fait l'app) → accepté, LA MARQUE EST EFFACÉE "user" sur la seule borne visée → accepté, rang inchangé, seul ce champ change deux renommages voisins ensuite → "user" SURVIT Donc le badge a bien disparu d'une borne que personne n'avait classée, au renommage d'une autre charge — et la fragilité ne touche que "auto". ⚠️ TROUVAILLE 1 — un refus arrive en `status: "success"`, le verdict étant dans params.energyError. Le service de l'app teste bien les deux ; une sonde jetable écrite le jour même, non — elle a annoncé « ACCEPTÉ » sur une écriture que la box venait de rejeter, et c'est le journal de la box qui a rétabli les faits. La raison lisible n'existe que là : « la charge "Simulated wallbox 2" prétend un rankOrigin "auto" que le moteur n'a pas posé ». Côté RPC il ne reste que le code. §10 mis à jour avec la table et ce piège. ⚠️ TROUVAILLE 2 — l'insertion en queue CASSE la contiguïté des domaines : ecs, ev, ev, heating, ev. La note d'ordre entrelacé s'allume donc à juste titre, et prévient que le premier enregistrement regrouperait — remontant au rang 3 une borne que le moteur avait rangée en queue. Ce n'est pas un défaut de LM-1209, c'est son prix dès que la dernière charge n'est pas du même domaine. Trois lectures possibles, portées au brief moteur ; l'app ne change rien tant que ce n'est pas tranché. Et une erreur de ma part, dans une sonde jetable et pas dans l'app : j'ai comparé un id de borne SANS accolades à un id de charge qui en porte — le piège du §9, que j'avais écrit moi-même la veille. Les chemins de l'app normalisent (chargingModeOf) ou comparent l'id à lui-même (adoptRank) ; le script, lui, ne trouvait jamais sa cible. État du banc laissé : libellés restaurés, borne 2 en rankOrigin "user" au rang 5. Le "auto" est consommé — il faudra un nouvel appairage pour revoir le badge. 226 tests inchangés. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
543 lines
29 KiB
Markdown
543 lines
29 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 d'une AUTRE charge, `"auto"` renvoyé verbatim | `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** |
|
||
|
||
Le coût annoncé est donc réel et vérifié : le badge « rang posé par la box » a disparu d'une
|
||
borne que personne n'avait classée, au renommage d'une autre charge. La fragilité ne touche
|
||
que `"auto"` — `"user"`, lui, traverse les gestes voisins.
|
||
|
||
> ⚠️ **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.
|