fix(recharge): le SOC véhicule inventé disparaît — la grandeur n'existe pas

_SocProgress affichait « 62 % » et « Aujourd'hui à 07:30 » en dur, sur un écran client.
Laissé de côté au lot précédent comme préexistant ; ça ne tient plus, une promesse fausse
ne se périme pas.

Le correctif attendu — brancher la vraie valeur — n'existe pas. Vérifié sur .75 : sur les
58 classes de la box, aucune classe portant l'interface evcharger ne déclare d'état de
charge, aucune ne porte l'interface car, et les deux bornes du banc publient pluggedIn,
charging, maxChargingCurrent, phaseCount, sessionEnergy — jamais un pourcentage.

Le seul batteryLevel de l'installation est celui de la batterie de la MAISON (Fronius
Storage, 50 %, rendu visible par le correctif energystorage du plugin SunSpec). C'est une
autre grandeur : l'afficher en face d'une cible de recharge remplacerait un chiffre faux
par un chiffre faux et crédible. Retiré, avec la barre de progression — une jauge sans
grandeur mesurée est un dessin — et la carte dit maintenant pourquoi l'avancement n'est pas
affichable. Reste ce que l'app SAIT : la cible qu'elle vient elle-même d'écrire.

Le brief 3g-2 est rapatrié depuis le dépôt plugin (la forge étant à terre, il n'avait pas
pu voyager) et CLAUDE.md est recalé dessus. À noter, la description trompeuse de
SetChargingInfo signalée hier est corrigée sur la box : elle énumère désormais tous les
défauts et conclut « send it back complete ».

Le prompt 3g-2 déposé à la racine est commité tel quel, il était déjà indexé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
This commit is contained in:
Patrick Schurig 2026-08-27 14:33:25 +02:00
parent 24822ce30b
commit f7fd91edde
3 changed files with 193 additions and 160 deletions

View File

@ -44,7 +44,7 @@ nymeaService.call('NymeaEnergy.SetChargingInfo', {
'chargingMode': 'ChargingModeEco', // requis, énumération ci-dessous
'targetPercentage': 80, // o: Uint — charge cible
'endDateTime': 1786250400, // o: Uint — horodatage, PAS une chaîne
'repeatDays': [1, 2, 3, 4, 5], // o: Int[]
'repeatDays': [1, 2, 3, 4, 5], // o: Int[] — 1 = LUNDI … 7 = dimanche, jamais 0
'assignedCarId': carId, // o: Uuid
'spotMarketChargingEnabled': false, // o: Bool
'dailySpotMarketPercentage': 30, // o: Uint
@ -247,7 +247,7 @@ batteryGreen boostRed pvGreen minPvBlue accentTeal
---
## Télémétrie d'arbitrage — deux pièges de lecture
## Télémétrie d'arbitrage — les pièges de lecture
**1. `funding` — ne jamais sommer `allocatedW` sans filtrer.** Depuis `+etm22` une borne
peut figurer dans `loads[]` au titre du surplus **ou** du réseau (échéance, tarif
@ -256,15 +256,45 @@ dynamique). L'identité vérifiable est : *somme des `allocatedW` financés au s
sans filtrer donne un écart que rien ne permet d'interpréter. Omis en mode dégradé — sans
plan, pas de financement. Cf. `LoadTelemetry.surplusAllocatedW`.
**2. ⚠️ Pour une BORNE, `allocatedW` et son motif sont une INTENTION, pas un ordre.**
`adjustEvChargers()` re-décide le surplus après le waterfall et passe outre le refus de
l'arbitre : relevé sur `.75`, l'arbitre publie « 0 W / `BELOW_MIN_POWER` » et le proxy
commande `ON` à 6 A dans le même cycle. L'état réel se lit dans
`mechanism.chargingEnabled` / `mechanism.currentA`, qui viennent du Thing. **Ne pas écrire
« la borne ne charge pas parce que le budget est sous le plancher » à partir du seul
motif.** La réserve **ne vaut pas** pour l'ECS et la PAC, où l'allocation publiée **est**
la commande. Elle tombe avec le lot plugin **3g-2** ; côté app, elle est portée par
`LoadTelemetryEntry.allocationIsCommand`.
**2. `allocatedW` est ce qui a été COMMANDÉ — jamais ce que la charge tire.**
La mise en garde « pour une borne, ce n'est même pas une commande » est **levée** :
depuis `+etm23`, `adjustEvChargers()` ne commande plus, l'arbitre est seul (mesuré : 9
commandes à la borne, 9 par l'arbitre, 0 par le proxy). Mais ce qui devient vrai, c'est
que **personne d'autre ne commande** — pas que la charge obéit. Un plafond matériel, un
véhicule qui refuse, un câble débranché font toujours diverger l'ordre et la réalité.
L'écart se lit dans `measuredW` et dans la charge utile `mechanism` (`chargingEnabled`,
`currentA`, `phaseCount`, `pluggedIn`), **jamais dans `allocatedW`**.
**3. `EV_GRID_START` partage une allocation entre les DEUX compteurs du budget.** C'est le
seul motif qui le fasse : `budgetW` vient du surplus et entre dans `budget.allocatedW`,
`gridW` est acheté au réseau et entre dans `budget.evReservedW`, avec
`budgetW + gridW == allocatedW`. Toute réconciliation doit traiter cette ligne à part —
cf. `LoadTelemetryEntry.surplusShareW`. Motif **jamais vu sur machine** au 2026-08-27 :
la clé est prête, rien n'est bâti autour.
**Bornes en configuration (`+etm23`)** — toute borne détectée reçoit d'office une entrée
`GetLoadConfig` : `adapter: "evcharger"`, `mode: "dynamic"`, `domain: "ev"`, plus
`label` / `priority` / `enabled`. **Aucune charge utile de mécanisme** — ni `relays`, ni
`sgReady`, ni `powerLevels`/`maxPowerW`/`minPowerW`, ni `minOnS`/`minOffS` : les limites
d'une borne viennent du Thing et changent avec le véhicule branché. L'aller-retour verbatim
reste neutre (vérifié sur `.75` le 2026-08-27, quatre entrées). Son rang est un
`priority` ordinaire — **il n'y a pas de second système de priorité pour les bornes**, et
le glisser-déposer marche sur une seule liste.
> ⚠️ **Le rang par défaut d'une borne n'est pas tranché.** À la création, elle reçoit « le
> plus petit rang existant moins un, borné à 1 » ; quand une charge occupe déjà le rang 1,
> l'intention dégénère en **égalité** (sur le banc : trois charges à 1). Le tri reste total
> — l'identifiant départage — donc l'ordre est reproductible, mais **reproductible n'est
> pas choisi**. Ne pas présenter l'ordre affiché avant réglage comme un choix.
**Une borne configurée absente de `loads[]`** = *hors arbitrage en ce moment* — aucun
véhicule branché (depuis `+etm24`), pas de voiture assignée, ou mode manuel. Jamais
« perdue », jamais « désactivée ».
**Aucun SOC de véhicule n'existe dans ce contrat.** Vérifié sur `.75` : sur 58 classes,
aucune classe `evcharger` ne déclare d'état de charge et aucune ne porte l'interface `car`.
Le seul `batteryLevel` est celui de la **batterie de la maison**. Ne pas l'afficher en face
d'une cible de recharge : c'est une autre grandeur.
**Motif `BATTERY_RESERVE`** — params `socPercent`, `reservePercent`, `withheldW`, tous
entiers **déjà en pourcentage** (ne pas confondre `reservePercent` = 40 avec

View File

@ -1,149 +1,140 @@
# Brief — du moteur vers l'app
# Brief agent app — ce que 3g-2 change pour l'écran
Ce que l'agent plugin (`etm-powersync-energy-plugin-etm`) constate et qui **change quelque
chose côté app**. Une section datée par lot. Le plus récent en haut.
**Émis par** : l'agent plugin, après 3g-2 · **Date** : 2026-08-27
**Banc** : `.75`, plugin `1.15.2+etm24`, nymea `1.15.2+202606191336~trixie1`
**Contexte** : `docs/RELEVE_3g2.md` (ce dépôt) porte les traces machine, cycle par cycle.
**Contrat** : `INTERFACE.md` fait autorité — ce brief ne fait qu'en signaler les mouvements.
> Quatre points. Le deuxième **retire une mise en garde** que l'app portait, et il est le plus
> intéressant pour vous : du code conditionnel peut disparaître.
---
## 2026-08-26 — passage machine 3g-1 sur le banc `.75`
## 1. `loads[] ⊆ GetLoadConfig` est RÉTABLI
**Contexte** : `powersync-energy-plugin-nymea 1.15.2+etm22` déployé sur `.75`. Relevé complet
dans le dépôt du plugin, `docs/RELEVE_3g1.md`.
3g-1 avait rompu l'invariant sans le signaler : les bornes entraient dans `loads[]` de
`GetLoadTelemetry` **sans figurer dans aucune `GetLoadConfig`**. C'était exactement la charge
fantôme que le lot B-bis avait supprimée, réapparue par l'autre bout — et vous vous appuyez sur
cet invariant pour résoudre libellé, domaine et rang de chaque charge publiée.
---
### 1. `SetChargingInfo` substitue `targetPercentage = 80` à un champ absent — **en silence**
**Envoyer un `chargingInfo` sans `targetPercentage` ne laisse pas la valeur en place : elle est
remplacée par 80.** Constaté en restaurant le banc — le champ valait `0`, j'ai réécrit l'objet
sans lui, il valait `80` à la relecture.
**Cause** : `ChargingInfo` est reconstruit intégralement à chaque écriture (`unpack<ChargingInfo>`),
et son membre C++ porte `m_targetPercentage = 80` comme valeur par défaut. Un champ absent du
JSON n'est donc pas « inchangé » : il **retombe sur le défaut**. Cela vaut pour tout l'objet,
`targetPercentage` n'est que le cas où le défaut n'est pas neutre.
**Et la substitution est muette.** Vérifié sur machine, journal du banc à 17:35:56 :
- la réponse RPC est `EnergyErrorNoError` — aucune erreur, aucun avertissement ;
- le seul avertissement du chemin d'écriture ne se déclenche que pour `targetPercentage > 100` ;
- la seule trace au journal est une ligne de **debug** qui imprime `Target percentage: 80`,
c'est-à-dire le **résultat** — jamais le fait qu'un champ était absent ;
- la notification `ChargingInfoChanged` qui suit porte 80, comme si l'utilisateur l'avait choisi.
> **Ce que l'app doit en faire.** Toujours envoyer `targetPercentage`, même inchangé — donc
> lire l'objet, patcher le champ voulu, réécrire l'objet complet (le même chemin que pour
> `priority` dans `LoadConfig`). Et **ne jamais afficher `targetPercentage` comme une cible
> choisie par l'utilisateur** tant que l'écran n'a pas lui-même écrit cette valeur : 80 peut
> être une valeur héritée d'une écriture qui ne parlait pas de cible du tout.
Le risque concret : un écran qui change le seul mode de charge repose une cible de 80 % à
l'insu de l'utilisateur, sans que rien ne le signale.
---
### 2. `GetPowerBalanceLogs` en `SampleRate15Mins` — **RIEN À SIGNALER, l'alerte était fausse**
J'avais signalé que ce taux rendait des échantillons tous à zéro. **C'était mon erreur de
lecture, pas un défaut de la box.** Je la consigne quand même pour qu'elle ne circule pas.
Ce qui s'est passé : ma sonde lisait les clés `currentPowerProduction` / `currentPowerConsumption`
alors que les entrées de log portent **`production`, `consumption`, `acquisition`, `storage`** —
sans le préfixe `currentPower`, contrairement à `GetPowerBalance`. Tout tombait donc sur le
défaut `0`.
**Vérifié après coup, dans les deux sens :**
- par RPC, mêmes bornes de temps, avec les bonnes clés : **51 échantillons, 51 non nuls** ;
- dans la base du banc (`/var/lib/nymea/energylogs.sqlite`), la table `powerBalance` porte
**7 908 lignes à `sampleRate = 15`**, du 2026-06-05 à aujourd'hui 16:30, valeurs réelles.
Les autres taux (1, 60, 180, 1440, 10080, 43200) sont peuplés aussi.
> **Conséquence pour l'app : l'historique 90 jours n'est bloqué par rien de ce côté.** Le seul
> piège est le nom des champs — **les entrées de log ne portent pas le préfixe `currentPower`**,
> à la différence de `GetPowerBalance`. Un modèle qui réutilise les mêmes clés pour les deux
> appels lira des zéros sans la moindre erreur. C'est exactement ce qui m'est arrivé.
---
### 3. Contrat neuf — `loads[].funding`, et les bornes entrent enfin dans `loads[]`
#### 3.1 Les bornes franchissent la frontière RPC (`+etm22`)
Jusqu'ici `buildTelemetry()` **sautait les bornes** : elles étaient arbitrées, leurs décisions
apparaissaient au journal du plugin, mais `GetLoadTelemetry` n'en publiait aucune. L'écran ne
pouvait donc pas les voir. C'est corrigé.
`loads[]` contient désormais les bornes de recharge, avec leur `decision`, leur `mechanism`
(`kind: "evcharger"`, `chargingEnabled`, `currentA`, `phaseCount`, `pluggedIn`) et leur
`faultCode` le cas échéant.
Les motifs EV **`EV_SURPLUS`, `EV_ECO_MIN`, `EV_IDLE` ne sont plus émis** : une borne reçoit
maintenant les motifs communs à tous les mécanismes — `SURPLUS_SETPOINT`, `BELOW_MIN_POWER`,
`SURPLUS_INSUFFICIENT`, `LOAD_UNAVAILABLE`. Les trois codes restent rendus côté plugin pour
qu'un journal ancien reste lisible, mais plus rien ne les produit. `EV_SPOT_MARKET` et
`EV_DEADLINE` subsistent.
#### 3.2 Le champ `funding` — `"surplus"` ou `"grid"`
**Nouveau, optionnel (`o:`), présent dès qu'il y a un plan.** Il devient nécessaire du seul fait
que les bornes sont dans `loads[]`, car deux d'entre elles peuvent y être pour des raisons
différentes :
| `funding` | d'où vient l'allocation | où elle est comptée |
|---|---|---|
| `"surplus"` | la cascade (waterfall PV) | dans `budget.allocatedW` |
| `"grid"` | le proxy — échéance de départ, tarif dynamique — qui **soutire au réseau** | dans `budget.evReservedW` |
> **Sommer `loads[].allocatedW` sans filtrer sur `funding == "surplus"` donne un écart avec
> `budget.allocatedW` que rien ne permet d'interpréter** : défaut du moteur, ou borne servie au
> réseau ? L'identité à vérifier est donc : **somme des `allocatedW` des charges financées au
> surplus == `budget.allocatedW`**.
**Omis en mode dégradé** : sans plan, il n'y a pas de financement. Règle maison habituelle —
omis, jamais nul.
#### 3.3 ⚠️ Mise en garde — pour une borne, l'allocation publiée est une INTENTION, pas un ordre
**`adjustEvChargers()` re-décide le surplus après le waterfall et passe outre le refus de
l'arbitre.** Observé sur le banc, deux lignes consécutives du même cycle (17:34:00) :
**Depuis `+etm23`, toute borne détectée reçoit d'office une entrée `LoadConfig`.** Sur le banc,
`GetLoadConfig` est passé de **2 à 4 entrées** au premier cycle après la mise à jour :
```
[Arbitre] "{09e520fd…}" → "Budget 1096 W sous le premier palier … — 0 W"
---------------- Adjusting chargers ----------------
Executing action Simulated wallbox to power: ON, Charging current: 6A, Issuer: Surplus
chauffe-eau adapter=relay-router prio=1 domain=ecs
PAC banc adapter=sg-ready prio=2 domain=heating
Simulated wallbox adapter=evcharger prio=1 domain=ev ← nouvelle
Terra AC Charger (TCP) adapter=evcharger prio=1 domain=ev ← nouvelle
```
L'arbitre refuse et publie `BELOW_MIN_POWER` ; le chemin d'exécution du proxy commande `ON` à
6 A une milliseconde plus tard. **Tant que ce n'est pas traité (lot 3g-2, côté plugin), ce que
la télémétrie publie pour une borne n'est pas ce qui est commandé.**
Forme d'une entrée `evcharger` : `id` (le ThingId de la borne), `adapter: "evcharger"`,
`mode: "dynamic"`, `domain: "ev"`, plus `label` / `priority` / `enabled`. **Aucune charge
utile** — pas de `relays`, pas de `sgReady`, pas de `powerLevels`/`maxPowerW`/`minPowerW`, pas
de `minOnS`/`minOffS`. Les limites d'une borne viennent du Thing et changent avec le véhicule
branché ; les déclarer en configuration en ferait une seconde source de vérité.
> **Ce que l'app doit en faire.** Pour les bornes — et seulement pour elles —, ne pas présenter
> `allocatedW` et son motif comme l'état de la borne. Ce sont **la décision de l'arbitre**, ce
> qui n'est pas la même chose que ce que la borne fait. L'état réel se lit dans
> `mechanism.chargingEnabled` / `mechanism.currentA`, qui viennent du Thing.
>
> Concrètement : ne pas écrire « la borne ne charge pas parce que le budget est sous le
> plancher » à partir du seul motif. Les deux peuvent se contredire aujourd'hui, et c'est
> l'écran qui aurait l'air faux.
>
> Cette réserve **ne vaut pas** pour l'ECS, la PAC et les autres charges pilotées : là,
> l'allocation publiée **est** la commande.
**Rien à faire de votre côté pour que ça marche** : l'aller-retour `GetLoadConfig` →
`SetLoadConfig` verbatim est **neutre**, vérifié sur machine — l'objet relu est identique et
aucun adaptateur n'est reconstruit.
**Le sens de l'inclusion reste le même** : `GetLoadConfig` peut contenir des charges absentes de
`loads[]`. Pour une borne, ça veut dire *hors arbitrage* — pas de voiture assignée, mode manuel,
ou **aucun véhicule branché** (ce dernier cas depuis `+etm24`). Une borne configurée absente de
`loads[]` se lit « pas arbitrée en ce moment », jamais « perdue ».
---
### 4. Rappel — clés ARB à prévoir côté app
## 2. ⚠️ La mise en garde sur `allocatedW` des bornes TOMBE
Deux motifs sont émis par le banc et n'ont pas encore de rendu français dans `telemetry_text.dart` :
Vous portiez : *« pour une borne, `allocatedW` est la décision de l'arbitre et non l'état de la
borne »*. C'était juste, et ça ne l'est plus.
- **`BELOW_MIN_POWER`** — params `budgetW`, `minPowerW`, et `state` pour les mécanismes à états
(SG-Ready). Déjà traité dans `telemetry_text.dart`, à vérifier côté ARB.
- **`BATTERY_RESERVE`** — params **`socPercent`, `reservePercent`, `withheldW`**. Le
`kCodesReserveBatterie` de `telemetry_text.dart` est encore **vide**, et les clés qu'il
cherche (`batteryLevel`/`soc`, `threshold`/`batteryLevelConsideration`) **ne sont pas celles
publiées**. À aligner sur les trois noms ci-dessus.
**Le défaut derrière cette mise en garde** : jusqu'à `+etm22`, l'arbitre décidait et publiait,
puis `adjustEvChargers()` — le chemin d'exécution hérité de l'amont — re-décidait derrière lui
et commandait autre chose. La décision publiée n'atteignait pas le matériel.
Et un réglage à exposer dans l'écran installateur : **`batteryLevelConsideration`** — son défaut
d'usine est passé de 0,9 à **0,2** (à 0,9 avec une batterie à 50 %, le HEMS ne pilote plus rien,
sans erreur ni panne). En revanche **`minimalChargingCurrent` ne doit jamais être présenté comme
configurable** : il n'existe pas comme réglage, il se déduit de la borne et de la voiture.
**`+etm23` ferme ça.** Une charge, un commandeur : les actions EV du waterfall sont dispatchées
vers l'adaptateur, et les bornes ainsi commandées sortent du chemin proxy. Mesuré sur machine :
9 commandes émises à la borne sur la fenêtre d'observation, **9 par l'arbitre, 0 par le proxy**,
et 6 cycles sur 6 portent la ligne « commandée par le waterfall, pas de re-décision ici ».
**Conséquence pour vous** : `allocationIsCommand` doit rendre `true` **partout**, et les
branches conditionnelles qui en dépendaient peuvent disparaître.
**Une réserve, et elle est petite mais réelle** : l'allocation reste ce que l'arbitre a
**commandé**, pas ce que la borne **tire**. Un plafond matériel, un véhicule qui refuse ou un
câble débranché feront toujours diverger les deux. Ce que vous pouvez enfin affirmer, c'est que
*personne d'autre ne commande*. Pour l'écart commande/réalité, la charge utile `mechanism` de la
borne porte `chargingEnabled`, `currentA`, `phaseCount` et `pluggedIn` — c'est là qu'il se lit,
pas dans `allocatedW`.
---
## 3. Nouvelle clé ARB : `EV_GRID_START`
```
EV_GRID_START { budgetW, floorW, gridW } funding: "grid"
```
**Une borne qui DÉMARRE en soutirant au réseau.** Le surplus ne paie pas son plancher, mais le
réglage `acquisitionTolerance` (défaut 0,5) autorise l'appoint : à 0,5, une borne qui exige
4 140 W démarre dès 2 070 W de surplus et tire le reste du réseau.
`gridW = floorW − budgetW` est **ce qui est acheté**. C'est le chiffre qui fait de cette ligne
une décision, et pas un effet de bord : il mérite d'être montré, pas rangé dans un repli.
**Comptabilité — c'est le seul cas où l'`allocatedW` d'une charge se partage** entre les deux
compteurs du budget : `budgetW` vient du surplus et entre dans `budget.allocatedW`, `gridW`
vient du réseau et entre dans `budget.evReservedW`. `budgetW + gridW == allocatedW`, toujours.
Si vous réconciliez la somme des allocations avec le budget, c'est la ligne à traiter à part.
**Borné par construction** : le démarrage exige du surplus réel et consomme tout le budget
restant, donc **au plus une borne par cycle** peut soutirer.
> **Pas encore vu sur machine.** Guetté six cycles au banc, jamais fabriqué : le budget de la
> borne est passé de 385 W à 2 350 W sans s'arrêter dans la fenêtre de tolérance. Le motif est
> éprouvé par la simulation. Prévoyez la clé, mais ne construisez pas d'écran autour d'une
> chose que le terrain n'a pas encore montrée.
Seconde clé du même lot, plus rare : `PHASE_LIMIT { limitW, requiredW }` — c'est la **limite de
phase** qui interdit, pas le budget. Deux causes, deux gestes : l'une se règle en délestant la
maison ou en revoyant l'abonnement, l'autre en attendant le soleil.
---
## 4. Le rang d'une borne est configurable, et il se classe avec les autres
`LoadConfig.priority`, comme toute autre charge. **Il n'y a pas de second système de priorité**
et il n'y en aura pas : un classement propre aux bornes ne saurait pas exprimer
« VE1 > ECS > VE2 », et deux classements qui ne peuvent pas s'interclasser sont un défaut, pas
deux fonctionnalités. `ChargingInfo` ne porte donc **aucun** champ de rang.
**Le glisser-déposer marche donc sur une seule liste**, bornes comprises, et se pose par
`SetLoadConfig` comme le reste. Vérifié sur machine, y compris l'inversion de rang entre deux
charges.
Le tri est **total** : à rang égal, l'identifiant départage. Laquelle est servie est donc
reproductible d'un cycle à l'autre, même avant tout réglage.
> **Réserve sur le rang par défaut, et elle vous concerne.** À la création automatique, la borne
> reçoit « le plus petit rang existant moins un, borné à 1 ». Quand une charge occupe déjà le
> rang 1 — c'est le cas du banc — l'intention « en tête » **dégénère en égalité** : les deux
> bornes et le chauffe-eau sont tous à 1. Sans conséquence sur la reproductibilité, mais
> **l'ordre affiché à l'installateur avant qu'il n'ait rien réglé n'est pas un choix, c'est un
> artefact**. Ne le présentez pas comme un réglage tant que le point n'est pas tranché côté
> moteur (`docs/RELEVE_3g2.md` §3.2). Les vrais rangs se posent à la mise en service, par vous.
---
## Ce qui n'a PAS changé, et qu'il ne faut pas déduire
- **L'échéance de départ et le tarif dynamique restent au proxy**, financés au réseau
(`funding: "grid"`, motifs `EV_DEADLINE` / `EV_SPOT_MARKET`). Leur puissance vit dans
`budget.evReservedW`, pas dans `budget.allocatedW`.
- **`chargingState` de `ChargingInfo`** garde son sens et reste publié par l'arbitre pour les
bornes qu'il commande.
- **`SetChargingInfo` n'est toujours pas partiel** — l'objet remplace le stocké, et un champ
writable omis retombe sur son défaut. La description publiée le dit désormais, et
`INTERFACE.md` en donne la table. Lire → modifier → renvoyer complet.
- **`repeatDays` va de 1 (lundi) à 7 (dimanche)**, pas de 0 à 6. La documentation disait le
contraire ; le code a toujours refusé le 0.

View File

@ -299,7 +299,7 @@ class _EVChargingCardState extends State<EVChargingCard> {
// Hors échéance, l'app n'a pas de cible : elle n'en montre pas.
if (_deadlineEnabled) ...[
const SizedBox(height: 14),
_SocProgress(targetSoc: _targetSoc, currentSoc: 62),
_SocProgress(targetSoc: _targetSoc),
],
],
),
@ -465,10 +465,29 @@ class _ModeButton extends StatelessWidget {
}
/// Barre de progression SOC de la voiture avec target et valeur courante.
/// Cible de charge du véhicule — **la cible seule, jamais un état de charge**.
///
/// Cette carte affichait « 62 % » et « Aujourd'hui à 07:30 » en dur. Les deux sont
/// retirés, et il faut dire pourquoi le second correctif possible n'en est pas un :
///
/// **aucun SOC de véhicule n'existe dans ce contrat.** Vérifié sur `.75` le 2026-08-27,
/// sur les 58 classes de la box : aucune classe portant l'interface `evcharger` ne
/// déclare d'état de charge, aucune classe ne porte l'interface `car`, et les deux bornes
/// du banc publient `pluggedIn`, `charging`, `maxChargingCurrent`, `phaseCount`,
/// `sessionEnergy` — jamais un pourcentage.
///
/// Le seul `batteryLevel` de l'installation est celui de la **batterie de la maison**
/// (Fronius Storage, 50 %), rendu visible par le correctif `energystorage` du plugin
/// SunSpec. C'est une autre grandeur : la brancher ici afficherait la charge du bâtiment
/// en face d'une cible de voiture. Un chiffre faux et plausible est pire qu'un chiffre
/// absent — c'était déjà le défaut du 62 en dur, le remplacer par 50 le garderait en le
/// rendant crédible.
///
/// Reste donc ce que l'app SAIT : la cible qu'elle vient elle-même d'écrire. La barre de
/// progression disparaît avec la mesure — une jauge sans grandeur mesurée est un dessin.
class _SocProgress extends StatelessWidget {
final int targetSoc;
final int currentSoc;
const _SocProgress({required this.targetSoc, required this.currentSoc});
const _SocProgress({required this.targetSoc});
@override
Widget build(BuildContext context) {
@ -484,24 +503,17 @@ class _SocProgress extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Charge prévue jusqu\'à $targetSoc%',
Text('Charge demandée jusqu\'à',
style: EtmTokens.sans(size: 13, color: EtmTokens.navy)),
Text('$currentSoc%', style: EtmTokens.mono(size: 13, color: EtmTokens.blue)),
Text('$targetSoc%',
style: EtmTokens.mono(size: 13, color: EtmTokens.blue)),
],
),
const SizedBox(height: 2),
Text('Aujourd\'hui à 07:30', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: SizedBox(
height: 7,
child: LinearProgressIndicator(
value: currentSoc / 100,
backgroundColor: const Color(0xFFD6E6F2),
valueColor: const AlwaysStoppedAnimation<Color>(EtmTokens.blue),
),
),
Text(
'La box ne publie pas l\'état de charge du véhicule : l\'avancement ne peut '
'pas être affiché.',
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context)),
),
],
),