38 Commits

Author SHA1 Message Date
Patrick Schurig
def985d3a0 docs(§10): les six champs vus à la sonde — et la trajectoire qu'ils révèlent
Obligation déclarée sur le banc puis retirée (`chauffe-eau`, 4 000 Wh avant
12:00, +etm43, sept cycles). Capture sur disque avant la première écriture,
restauration DEPUIS le fichier, comparaison clé par clé : 0 écart.

La forme est celle annoncée, et elle porte plus que le motif : `decision` et
`progress` descendent au niveau, `deliveredWh` est absent sous `unmeasurable`,
`counts` tombe sur `draw.committedW`, et le motif racine porte `level`.
R1, R5 et R7 vérifiés sur le fil ; les deux identités tiennent à chaque cycle.

Deux choses que la mesure apprend, et qu'aucune lecture de code n'aurait données :

- au cycle 05:47 le surplus passe à −922 W : `comfort` tombe à 0 avec
  `SURPLUS_INSUFFICIENT` pendant que `eco` achète 767 W au réseau. Le motif
  RACINE vaut alors `SURPLUS_INSUFFICIENT` — un client d'avant `levels[]` lit
  donc « rien fait » sur une charge qui achète. Vue fausse, pas incomplète ;
- sous `unmeasurable`, `remainingWh` ne bouge pas et `remainingS` descend, donc
  `gridW` monte (755 → 767 en quatre cycles). Le plancher ne peut jamais se
  refermer : l'accélération jusqu'au mur est la trajectoire GARANTIE du régime,
  pas son cas limite. La maquette affiche désormais reste + temps, et pas
  seulement les watts de l'instant.

Question posée au moteur : la charge a un compteur (`totalEnergyConsumed: 10`)
et le régime sort quand même `unmeasurable`. Faut-il remplir `sessionWh` depuis
le compteur, et faut-il acheter quand on ne peut pas vérifier ?

`remainingWh` plutôt que `deliveredWh` est accepté, et R5 porte l'argument :
`remainingWh` n'est pas dérivable sous `unmeasurable`, qui est justement le
régime où l'on achète.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-01 07:48:39 +02:00
Patrick Schurig
7eefb93c6c docs: relevé frais sur charge réelle — sessionEnergy corrigé, chargingState confirmé
Lien Modbus rétabli, Skoda en charge, valeurs en mouvement.

- `sessionEnergy` : notre doc était EN RETARD, pas contredite. La V2C la publie
  depuis le plugin +etm13 et elle bouge (15,8015 → 15,8405 kWh). L'écart avec
  `chargeEnergy` vaut 7,2044 kWh aux deux échantillons pris à deux heures
  d'écart — constant au dix-millième : c'est l'accumulateur du plugin, et il
  prouve l'accumulation que le code seul ne montrait pas. LM-1009 C1 reste vraie
  pour les bornes sans cet état ; la V2C n'en fait plus partie. La maquette ne la
  nomme donc plus dans l'exemple « non mesurable ».
- `chargingState` reste `Idle` sur un lien VIVANT, pendant que la borne tire
  4 372 W : ce n'est pas de la péremption, personne ne maintient le champ.

Et un constat qui n'est pas dû au WiFi : la box a journalisé « Restoring manual
values of false 6 A » trois secondes après la coupure ; la borne est restée à
`power: true`, 7 A, et le lien est revenu sans que rien ne réapplique. En mode
manuel personne ne commande, donc l'écart est permanent — 4,4 kW tirés,
`draw.committedW` à 0. Question posée au moteur.

Aucun avancement n'est affiché pour autant : c'est une décision (D1), pas une
mesure qui manquerait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 15:15:49 +02:00
Patrick Schurig
2c9cca042e fix(borne): une borne injoignable ne montre ni le zéro forgé ni la valeur périmée
Mesuré sur `.75` le 2026-08-30 : quand le lien Modbus de la V2C tombe, la box ne
fige pas les états, elle publie des zéros — `pluggedIn` false, `charging` false,
`currentPower` 0 — alors que la Skoda était branchée et en charge à 4 356 W.
Avant ça, la dernière valeur connue reste publiée comme si elle était actuelle
(vérifié : aucun état ne bouge en 45 s pendant une charge supposée).

L'app refusait déjà d'inventer un zéro ; elle en relayait un. Sous
`connected == false`, `EvChargerLive` met `pluggedIn`, `charging` et
`currentPowerW` à `null` À L'ENTRÉE — un seul endroit, pas un test par écran — et
porte `joignable` + `messageEtat`. La tuile dit « Hors ligne » et reprend la
phrase du plugin, qui porte l'heure de la coupure et la piste WiFi.

Deux garde-fous dans les tests : `joignable == null` n'est PAS hors ligne (une
borne qui ne publie pas `connected` ne dit rien de son lien — supposer la panne
serait le symétrique du zéro forgé), et le MODE reste affiché hors ligne, parce
qu'il est stocké dans la box et non lu sur la borne.

Brief moteur : les zéros à la perte du lien, `chargingState` figé à `Idle`
pendant une charge réelle, et `ChargingModeNormal` qui n'est pas un boost — il
restaure les valeurs manuelles mémorisées (ici arrêt, 6 A) et sort la borne de
l'arbitrage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 13:43:31 +02:00
Patrick Schurig
faffc43dd9 docs: draw.committedW n'est pas sur la box — annoncé ≠ construit ≠ posé
CLAUDE.md le portait comme « annoncé livré, non vérifié ». Le fait est plus
simple : le code est écrit côté moteur, le cross-build n'a pas été fait, aucun
paquet +etm31 n'existe, la box porte +etm30 et la trame ne contient aucune clé
`draw`. La pose est relancée ; l'exception `EV_GRID_START` tient jusqu'à ce que
la sonde voie le champ sortir.

Et la règle qui a servi entre dans les règles de modification, à côté de celle
sur `JSONRPC.Introspect` : vérifier la VERSION POSÉE avant de croire une
livraison annoncée — `ssh etm@192.168.1.75 'dpkg -l | grep powersync-energy'`.
Un champ absent parce qu'il n'est pas déployé se lit exactement comme un champ
absent parce qu'il est omis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 09:25:02 +02:00
Patrick Schurig
43fd79ad62 feat(écran): « Commandé, pas encore mesuré » remplace « Réservé au véhicule »
Le moteur a livré la définition d'`evReservedW`, et il ne pouvait pas l'écrire
avant d'en avoir sorti la part réseau d'`EV_GRID_START` : un champ à deux natures
ne se documente pas. Le libellé tranché est le sien, parce qu'il dit la chose —
la latence entre l'ordre et sa lecture au compteur.

« Réservé au véhicule » affirmait deux faussetés : que c'est réservé, et que ça
regarde le véhicule plutôt que la latence de sa mesure.

La clé ARB garde le nom du champ de la box (`budgetEvReserved`), pas celui de
l'ancienne affirmation. Et la consigne qui compte reste écrite à côté : ce n'est
pas un registre, on ne réconcilie rien dessus — la nature du champ l'interdit,
pas son état de livraison.

`draw.committedW` : annoncé livré en +etm31, NON vérifié à la sonde. La box porte
`powersync-energy-plugin-nymea 1.15.2+etm30` au 2026-08-30 et ne publie aucune
clé `draw`. L'exception `EV_GRID_START` reste donc dans `surplusShareW` : c'est
la sonde qui autorise le retrait, pas l'annonce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 09:16:04 +02:00
Patrick Schurig
b01a716047 docs(R3): draw.committedW en préalable — et evReservedW n'est PAS un registre
Le moteur l'a trouvé en câblant `counts` : `budget.evReservedW` est un terme de
CORRECTION du budget, pas un registre d'allocation. Il mêle la correction
« commandé non encore mesuré » à la part réseau d'`EV_GRID_START`, et une borne
servie au réseau qui tire déjà ce qu'on lui a commandé y contribue 0 pour un
`targetW` de 7 000 W. `Σ counts == targetW` y serait vraie au premier cycle après
un changement et fausse ensuite : une identité dépendante du temps, qui passe
tous les tests et certifie un total faux. Ne pas livrer était le bon appel.

Réponse : `draw.committedW` en préalable, oui, sans réserve — et trois demandes :
par cycle, en watts, dans la même trame que `budget` ; publié SEUL, les quatre
autres champs de `draw` absents et jamais à 0 (un `authorisedW` forgé afficherait
un plafond de soutirage qui n'existe pas) ; et une définition d'`evReservedW`,
que nous affichons sous « Réservé au véhicule » — un libellé qui affirme une
réservation là où il y a une correction, et que nous ne renommerons pas au jugé.

Nous portions la fausse affirmation en cinq endroits (`CLAUDE.md` ×2, le modèle
×3, la description ARB) : retirée ici, sans changement de comportement. Elle ne
relève pas de la discipline « attendre la sonde » — celle-ci arbitre ce que fait
la box, pas ce que nous en avions cru, et la phrase est fausse aujourd'hui.

Il n'existe donc qu'UNE identité vérifiable, et elle ne couvre qu'un côté :
Σ targetW(surplus) == budget.allocatedW. Côté réseau, rien — pas « à vérifier
plus tard », rien, jusqu'à `draw.committedW`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 08:35:17 +02:00
Patrick Schurig
1d0112a6e4 docs(R3): counts retenu — et sur un niveau mixte, ni omission ni « mixed » publié
Arbitrage rendu au moteur avant qu'il n'écrive R3. La forme proposée est prise
telle quelle : `funding` reste, `counts` s'ajoute, `Σ counts == targetW`
toujours, publié même à une seule destination. L'exception `EV_GRID_START`
disparaît du contrat, remplacée par une règle vérifiable par niveau.

Trois apports de notre côté :

- « approximation » est le mauvais mot et coûterait le champ : quelqu'un
  voudrait le rendre exact, et un libellé par part dominante basculerait d'un
  cycle à l'autre sur 900/480. Règle demandée : **`grid` l'emporte dès qu'un
  watt est acheté** — déterministe, et vraie sans nouvelle décision pour le
  plancher éco du §10 ;
- `"mixed"` publié est refusé, et c'est notre propre code qui le dit : une
  troisième valeur tombe dans la branche par défaut de tout client — « surplus »
  — donc reproduit la panne de l'omission, avec un champ présent en plus pour
  faire croire le contraire. Et il serait dérivable de `counts`, donc supprimé
  un jour à juste titre. Notre `mixed` reste dérivé, local, jamais transporté ;
- ce que l'app promet : clés de `counts` traitées comme identifiants opaques,
  une clé inconnue dit « destination inconnue de cette version », jamais zéro.

Contrat, pas feuille de route : `CLAUDE.md` et `surplusShareW` gardent
l'exception écrite tant que la sonde ne l'a pas vue disparaître de la box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 08:24:16 +02:00
Patrick Schurig
6f19863620 docs(§10): réponse au §9 — deux passes, une commande, et la forme qui la publie
La maquette `docs/mockups/deux_passes_eco_confort_mockup.html` répond aux trois
questions du brief plugin §9 (= DESIGN_10 8.5), et sert de contrat au moteur.

- la liste reste ordonnée par RANG : l'ordre de service n'est pas une propriété
  de la configuration — il change d'un cycle à l'autre selon les planchers déjà
  atteints, donc une liste triée par lui bougerait sous le doigt ;
- une entrée par charge, deux lignes dans la carte : on dédouble là où l'objet
  affiché est la SÉQUENCE, jamais là où c'est la CHARGE — d'où un second écran,
  « ordre de service du cycle », en lecture seule ;
- « éco servi / confort non » s'affiche en deux états NOMMÉS : en fraction, le
  régime nominal se lirait comme une dégradation permanente.

Huit exigences pour `GetLoadTelemetry`, dont R2 (`funding` descend au niveau) et
R4, dont la prémisse a été corrigée par le moteur : `allocatedW` publie le
DÉCIDÉ, donc `Σ targetW == allocatedW` est une identité, et l'écart à afficher
vit entre le décidé et `mechanism.*` (appliqué). D'où le bloc « trois nombres ».

Contrat, pas feuille de route : aucun écran ne se construit avant `levels[]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 08:10:31 +02:00
Patrick Schurig
3f5f6e0d9f docs(correction): mon « écho refusé » était un écho PÉRIMÉ — deux cas, un symptôme
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
2026-08-29 10:44:14 +02:00
Patrick Schurig
ba5a8eb230 docs: le premier auto-provisionnement réel — trois cas observés, deux trouvailles
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
2026-08-29 10:38:48 +02:00
Patrick Schurig
761e1e369d docs(motif): un interdit d'écriture doit dire ce qu'il fait d'un écho inchangé
§10 du contrat de données, à côté du §9. Le §9 disait 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. Symptôme identique : muet.

L'app écrit verbatim (sinon un champ qu'une box plus récente porte serait perdu, en silence
et pour toutes les charges) ; le moteur refuse rankOrigin: "auto" à tout client (sinon un
écran fabriquerait une affirmation sur un geste du moteur). Leur produit : un simple
renommage rejeté en bloc dès qu'une installation porte une borne auto-provisionnée.

La règle doit porter sur le CHANGEMENT, pas sur la PRÉSENCE — même distinction que
l'effacement de rankOrigin, qui porte sur « rang différent pour cet id » et non sur
« écriture reçue ». Sinon la marque est fragile par construction : nettoyée à chaque
écriture, 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.

Le §10 porte aussi le contrôle à passer une fois par règle d'écriture : l'app peut-elle lire
la valeur (alors elle la renverra) · l'écho inchangé est-il tranché par écrit (sinon c'est
un trou) · que devient l'information au premier geste voisin (si elle disparaît, la publier
est un leurre).

Et le motif derrière le motif, parce qu'il s'est répété : TROIS FOIS EN DEUX JOURS, le
chemin exposé n'était pas celui qu'on regardait — l'écriture du mode visait la première
borne (trouvé en déplaçant les boutons), le refus sur rangs doublés menaçait la suppression
et non le glisser-déposer, le refus de "auto" menace le renommage et non la gestion des
rangs. Le point commun n'est pas la malchance : ces chemins TRANSPORTENT des données qu'ils
ne regardent pas, donc ils sont exposés à tout ce qui a été ajouté après eux.

Le brief moteur passe de la question à la demande argumentée ; le nettoyage côté app reste,
documenté comme contournement — le jour où l'écho passe, la fonction disparaît.

222 tests inchangés, analyze inchangé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-29 10:23:25 +02:00
Patrick Schurig
e26000945d fix(config): ne jamais renvoyer rankOrigin "auto" — un renommage aurait été refusé
Trouvé en relisant .75 après le déploiement d'+etm27, pas en écrivant le badge.

`rankOrigin: "auto"` écrit par un client est refusé : EnergyErrorInvalidParameter,
ensemble entier rejeté (vérifié sur machine). Or les deux chemins d'écriture de l'app
recopient les charges VERBATIM — c'est leur règle, et elle est bonne. Recopier verbatim une
entrée marquée "auto", c'est écrire "auto".

Conséquence, qu'aucun test ne montrait et que le banc ne peut pas produire aujourd'hui
(aucune borne auto-provisionnée depuis +etm27) : dès qu'une installation cliente portera
une borne détectée par le moteur, un SIMPLE RENOMMAGE aurait été refusé en bloc — et le
motif ne franchit pas la frontière RPC. Même famille que le refus sur rangs doublés d'hier,
même symptôme muet.

Le nettoyage est posé au point de sortie UNIQUE (NymeaService.setLoadConfig) plutôt que sur
chaque chemin : un chemin ajouté demain hériterait sinon du refus sans que rien ne
l'annonce. `"user"` n'est pas touché — il est permis — et une clé absente le reste : on n'en
fabrique pas.

⚠️ Ce que ça coûte est dit dans le code et remonté au brief : la marque du moteur est
EFFACÉE sur les charges concernées, y compris celles que le geste ne visait pas. Le badge
« rang posé par la box » disparaîtra donc d'une borne que personne n'a classée, au premier
renommage d'une autre charge. Question posée au moteur : un écho inchangé de "auto"
peut-il être toléré ? Trois options écartées, avec leurs raisons.

Et le relevé du jour précise la lecture : depuis +etm27 la clé sort TOUJOURS, à `""` quand
rien n'est déclaré — même convention que `domain` et `meterThingId`. Les quatre charges du
banc portent `rankOrigin: ""`. La traiter comme une valeur afficherait un badge sur toute
l'installation ; le getter la lit comme un silence, et quatre tests le figent.

222 tests (+4), flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-29 10:03:18 +02:00
Patrick Schurig
376acb2181 docs(motif): deux réponses de la même box, deux conventions — et aucun signal
Le piège des accolades n'était pas un détail de chargingModeOf(). C'est la deuxième
occurrence en une semaine d'un motif qui se répétera : deux appels JSON-RPC de la même box,
portant le même objet, n'emploient pas la même convention, et RIEN ne le dit. Écrit comme
tel dans docs/UI_data_contract.md §9, avec renvoi depuis les deux sites de code et depuis
tools/rpc/README.md — là où quelqu'un croise deux réponses brutes.

Les occurrences, leur symptôme :
  · GetPowerBalance (currentPowerProduction…) × GetPowerBalanceLogs (production…) →
    tout tombe à zéro, l'historique paraît vide sur 7 960 échantillons bien présents
  · GetChargingInfos (evChargerId sans accolades) × GetLoadConfig (id avec) →
    « mode non lu » sur une borne parfaitement lue, et la note « hors arbitrage » muette
  · famille voisine, même silence : currentPower* en W × total* en kWh, facteur 1000

Ce qui en fait un motif et pas trois anecdotes : le symptôme n'est JAMAIS une exception,
c'est une valeur neutre plausible, et un test unitaire qui construit ses deux côtés avec la
même convention passe aussi. D'où les trois gestes écrits au §9 — sonder les deux appels
côte à côte sur les charges utiles brutes, éprouver le rapprochement sur les deux formes
réelles, et se méfier d'un vide qui ne coûte rien à expliquer.

Et le lien avec une règle qu'on suivait déjà sans la relier à ça : une valeur neutre issue
d'un croisement raté est indiscernable d'une vraie absence. « Non lu » laisse une chance de
trouver le bug ; « 0 » l'enterre.

── Campagne V2C arrêtée, et la réserve sessionEnergy avec elle ────────────────

La Trydan non-Pro est en WiFi ESP32 sans repli filaire, lien inexploitable à deux mètres du
point d'accès : 70 % de scrutations avortées, 12 bascules en injoignable en vingt minutes.
Le modèle sort du catalogue ETM ; seule la Trydan Pro (port Ethernet) y reste.

La trace qui lève C1 — chargeEnergy à zéro AVEC un sessionEnergy qui continue — n'a donc
pas été prise et ne viendra pas de cette borne. Les notes restent en place telles quelles,
avec la raison mise à jour : elles seront levées sur une Trydan Pro. Aucun avancement de
charge affiché d'ici là.

164 tests, flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 13:15:51 +02:00
Patrick Schurig
95769938fc fix(ev): la tuile des favoris devient muette — elle lit la borne, ou le dit
Elle reste : la retirer aurait laissé un trou dans une liste que le client a composée
lui-même. Elle affiche maintenant ce que la box publie, et là où il n'y a rien à lire,
elle le dit.

Ce qui est lu, et sa source
  · branché / en charge   ← états pluggedIn, charging du Thing
  · puissance             ← état currentPower du Thing
  · mode                  ← GetChargingInfos + notification ChargingInfoChanged

Absent = « non publié », jamais zéro. Un mode que l'app ne connaît pas se lit « mode non
reconnu (X) » plutôt que d'être deviné, et aucun bouton n'est allumé tant que la box n'a
pas dit ce qu'elle porte.

L'écriture restitue le refus, sinon elle n'existe pas. Les pastilles de 7 à 10 px qui
avalaient les codes d'erreur de setChargingInfo passent à 44 px, montrent l'attente, et
affichent le refus tel que la box le nomme. Et l'app ne tient plus d'état de mode local :
après une écriture acceptée, elle RELIT GetChargingInfos. Elle se souvenait de ce qu'elle
avait demandé, ce qui n'est pas ce que la borne porte.

La fiction perd sa porte d'entrée. chargingMode, chargingPower (3,6 kW) et
solarSourcePercent (82 %) sont SUPPRIMÉS de EnergyData : trois valeurs par défaut que
_updateEnergyData ne touchait jamais. Ce que la box publie sur une borne vit désormais
dans EvChargerLive, champ par champ, nullable.

⚠️ Et le Sankey du tableau de bord en tirait un flux « Voiture » permanent à 3,6 kW,
voiture débranchée. Il est branché sur la mesure de la borne — « non lu » quand il n'y en
a pas. Mais la même vue invente encore PAC = 38 % de la maison, Eau chaude = 18 %,
Autres = 22 %, dont un seul marqué « estimé » : entrée 🔴 au TODO, avec la source réelle
(GetLoadTelemetry publie measuredW par charge : 1500 W et 800 W ce midi). Brancher le
Sankey dessus est une refonte de l'écran d'accueil, pas un correctif — à décider.

Deux attentes tombent, une reste (relevé Integrations.GetThings sur .75, cet après-midi)
  · currentL1/L2/L3 est DÉPLOYÉ (5,86 / 5,94 / 6,02 A) — powerL1/L2/L3 a disparu
  · phaseCount EXISTE sur la Trydan (3) — la note qui le disait absent était en retard
  · sessionEnergy existe (4,229 kWh) MAIS vaut exactement chargeEnergy : c'est ce qu'on
    attend d'une session sans interruption, donc ça ne prouve rien. LM-1009 §C1 reste
    entière tant qu'une remise à zéro de chargeEnergy n'a pas été vue avec un
    sessionEnergy qui continue.

Le brief moteur dit désormais du masquage WRITE_FAILED que ce n'est pas « ne se produit
pas » mais « ne s'est pas encore produit » : il attend une charge variable qui perd son
Thing, et le banc n'a pas eu ce cas.

154 tests (8 nouveaux sur la tuile, dont les cibles tactiles et la restitution du refus),
flutter analyze à 27 remarques, aucune nouvelle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 12:49:28 +02:00
Patrick Schurig
33d89a8c26 fix(ev): la réserve THING_MISSING est levée par la mesure, et la carte véhicule part
Deux gestes que le relevé du banc a tranchés, dans les deux sens.

1. THING_MISSING — MESURÉ, la réserve tombe

GetLoadTelemetry sur .75, 2026-08-28 10:29 UTC : la Terra AC supprimée est publiée
available=false · faultCode=THING_MISSING · decision=LOAD_UNAVAILABLE{frozenW:0} ·
mechanism.kind=evcharger. Le code SORT. Le test de rendu ne repose plus sur une trame
composée mais sur cette capture (test/fixtures/loadtelemetry_hems75_thingmissing.json).

Le masquage relevé mardi (etmvariableloadadapter.cpp:148-150, m_faulted testé avant que
THING_MISSING ne puisse s'exprimer) concerne l'adaptateur des charges VARIABLES — relais
et SG-Ready. L'adaptateur evcharger n'a pas ce chemin, d'où la différence. Il n'est donc
pas corrigé, seulement contourné par le hasard du chemin de code : remonté au brief
moteur avec la trame, parce que les deux codes envoient le client faire deux gestes
différents — « écriture échouée » fait démonter un coffret, « appareil absent » fait
rouvrir nymea.

2. EVChargingCard — elle part, et la monter aurait publié de la fiction

Trois des grandeurs qu'elle affiche ne sont JAMAIS lues sur la box : chargingMode,
chargingPower (3,6 kW) et solarSourcePercent (82 %) sont des valeurs par défaut de
EnergyData — _updateEnergyData ne les touche pas, et le mode n'est écrit qu'en optimiste
après une commande, jamais relu par GetChargingInfos. Sa feuille « Paramètres de charge »
proposait en plus un courant minimum, qui n'existe dans aucun contrat, et son bouton
« Enregistrer » ne faisait que Navigator.pop : rien n'était écrit. Ce n'était pas du code
mort à ranimer, c'était un écran à réécrire depuis le modèle.

⚠️ Et la surface vivante est pire que la morte : _EVWidget (favorites_screen.dart:419)
affiche les MÊMES valeurs par défaut comme si elles étaient mesurées — « 3600 W »,
« 82 % solaire », « Mode PV » — et écrit le mode sur des pastilles de 7 à 10 px sans
restituer un refus. Entrée 🔴 au TODO, avec ce qu'une carte de recharge peut dire
aujourd'hui : le mode lu par GetChargingInfos, la puissance mesurée du Thing, l'échéance
comme intention saisie. Ni SOC (n'existe pas), ni chargeEnergy (remis à zéro à chaque
interruption), ni courant minimum, ni phaseCount.

146 tests passent. flutter analyze : 27 remarques, deux de moins qu'avant — les deux
étaient dans le fichier supprimé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 12:36:00 +02:00
Patrick Schurig
23a84fec43 feat(dashboard): le bilan du JOUR — et deux bugs d'unité trouvés en le posant
L'entrée 🔴 du TODO. Aucune des quatre tuiles n'était journalière : deux pourcentages
instantanés, deux compteurs cumulés depuis l'origine dont deux portaient le mot
« aujourd'hui ». Elles montrent désormais la journée, sous un en-tête qui le dit une fois.

MÉTHODE : une DIFFÉRENCE de compteurs entre minuit local et maintenant, jamais une
intégration des puissances. La première est une mesure, prise sur les mêmes compteurs que
ceux du fournisseur ; la seconde est une estimation qui suppose que chaque échantillon
représente son intervalle. Mesuré sur .75 : l'intégration rend 77,70 kWh là où la différence
rend 78,31 — 0,8 % d'écart, systématiquement à la baisse. Il n'y a donc PAS de repli
silencieux de l'une vers l'autre : quand la mesure n'est pas exploitable, le type le dit.

DEUX BUGS D'UNITÉ, trouvés en cherchant à savoir ce que valaient les compteurs. Aucune
documentation ne tranchait, donc recoupement sur machine : l'intégration des puissances (en
W) rend 77 697 Wh quand la différence de totalProduction rend 78,310. Le rapport est mille —
LES COMPTEURS SONT EN kWh, et le modèle Dart les nommait Wh.

  1. L'écran Énergie divisait ces kWh par mille pour les afficher : une journée de 78 kWh
     s'y lisait « 78 Wh », sous un axe intitulé « Bilan énergétique (Wh) ». Invisible sans
     connaître l'ordre de grandeur attendu.
  2. La simulation accumulait des Wh : un écran juste en simulation aurait cassé d'un
     facteur mille contre une vraie box.

LE CONTRÔLE QUI VALIDE LE BILAN : production + soutiré == consommation + injecté. C'est le
seul recoupement disponible sur des compteurs qu'on ne peut pas vérifier autrement, et il
tient EXACTEMENT sur .75 — 112,244 des deux côtés sur 21,75 h. Faux, il désigne un compteur
cassé ou une remise à zéro, et la carte le signale au lieu de présenter comme une mesure ce
qui ne se recoupe pas.

Les trois pièges du TODO n'ont pas été repayés : bornes à minuit LOCAL via
GetPowerBalanceLogs · décodage délégué à fetchPowerBalanceLogs, qui porte déjà les bonnes
clés sans préfixe currentPower · plausibilité vérifiée sur les DEUX bornes avant de
soustraire, parce que deux valeurs absurdes peuvent avoir une différence d'apparence saine.
Plus un quatrième trouvé en chemin : un compteur qui RECULE (redémarrage en milieu de
fenêtre) donne un bilan négatif, et un max(0, …) le rendrait faux en silence.

Rien ne s'affiche jamais à zéro quand c'est inconnu. Sans production, le taux
d'autoconsommation est null et non 0 % — à 3 h du matin, « 0 % » se lirait comme un défaut
alors qu'il n'y a rien à autoconsommer.

VÉRIFIÉ SUR APPAREIL contre .75, en lecture seule (le banc est en préparation V2C, rien n'a
été écrit) : 32 échantillons depuis minuit, prod 28,37 · conso 26,55 · import 11,95 ·
export 13,77 kWh, identité OK, et les tuiles affichent 51 % / 55 % / 13,8 / 11,9.

Le passage sur appareil a trouvé ce qu'aucun des 138 tests unitaires ne pouvait voir : LES
QUATRE LIBELLÉS DÉBORDAIENT — « Autoconsomm… », « Vers réseau · aujou… ». Le suffixe
« aujourd'hui » répété quatre fois mangeait le nom de la grandeur ; il est dit une fois en
en-tête. Le harnais d'appareil échoue désormais sur tout libellé finissant par « … ».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-28 07:57:57 +02:00
Patrick Schurig
74b7a84913 docs(maquette): la recharge accélérée n'est pas un vidage — et le brief moteur qui va avec
Trois corrections de Patrick sur la section batterie, plus le brief séparé.

1. LE BOOST CHANGE DE NATURE, et ce n'est pas un détail de libellé. Ce n'est pas « vider la
   batterie dans la voiture » : c'est charger la voiture plus vite que l'abonnement ne le
   permet. Un client en 6 kVA veut 7,5 ; le réseau donne 6, la batterie comble l'écart, le
   disjoncteur ne saute pas.

   Le réglage devient donc une PUISSANCE DE CHARGE VISÉE, pas une cible de SOC batterie, et
   la première des trois fins devient « la voiture a atteint sa cible » — le plancher
   batterie reste, mais comme garde-fou et non comme objectif. On ne cherche pas à vider la
   batterie, on cherche à finir la charge plus tôt.

   Ma réserve « le boost pourrait acheter en cachette » tombe, et elle tombe à l'envers : ce
   mécanisme existe précisément pour NE PAS dépasser ce qu'on a le droit de soutirer.

   Écrit dans la note : ce bloc appartient au délestage de LM-1006-1 vu par l'autre bout —
   au lieu de réduire les charges pour tenir sous le plafond, on complète par la batterie.
   Même objet, pas un mécanisme parallèle. Et c'est un argument de vente réel, donc il est
   dit à l'écran : recharger plus vite sans passer de 6 à 9 kVA.

2. L'INTERRUPTEUR DE DÉCHARGE PROMETTAIT CE QUE LE MÉCANISME NE TIENT PAS. Il disait « la
   maison continue d'être servie ; seule la voiture ne l'est pas ». enableDischarging est
   binaire et l'onduleur ne voit qu'une consommation nette : coupé, le réfrigérateur passe au
   réseau comme la voiture. Reformulé en « geler la batterie », avec le mécanisme expliqué.
   La version modulée — plafonner dischargingRate sur la consommation hors borne, recalculé
   à chaque cycle — part au brief comme question, pas à l'écran comme réglage.

   C'est exactement la classe de défaut traquée depuis trois jours, et je l'avais écrite
   moi-même.

3. LA RÉSERVE ADAPTATIVE DEVIENT UN PLANCHER DE SECOURS. Mon avertissement sur Battery Life
   était juste mais incomplet : il disait ce que le bloc n'est pas sans dire ce qu'il est.
   Combien d'autonomie garder pour une coupure est une préférence de RISQUE — elle dépend du
   congélateur, du télétravail, de la fiabilité du réseau local. Héos ne pourra jamais la
   décider à la place du client, ce qui la qualifie exactement selon le critère qui a fait
   sortir les quatre autres blocs.

Deux détails de cohérence : la batterie est la seule charge sans compteur dédié possible —
elle publie currentPower elle-même — et c'est DIT plutôt qu'omis en silence comme dans les
cinq autres écrans. Et chargingRate trouve sa place : plafond de soutirage sur la charge
réseau, même motif que la recharge accélérée à l'envers.

Le brief moteur est à part, dans BRIEF_plugin_depuis_maquettes.md : les trois questions de
mécanisme (interdit actif, bail qui expire, controlHealthy), plus quatre que la recharge
accélérée soulève — la boucle à la minute contre un disjoncteur en secondes, la marge
dimensionnée contre le pire démarrage non piloté, le filet qui est la BORNE et non la
batterie, et la latence du Fronius à mesurer sur le banc. Avec la conséquence honnête écrite
noir sur blanc : en 6 kVA le gain réel est d'un à deux kilowatts, pas de 1,5 garanti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 21:46:03 +02:00
Patrick Schurig
b298c366d8 docs(maquette): la charge différée sort de l'écran — sans prévision, elle ne se règle pas
Quatrième passe, même logique que la précédente : ce qui demande un calcul revient à Héos,
pas à un formulaire. Démarrer assez tard pour arriver à 100 % juste avant la pointe suppose
de savoir ce que le soleil va faire — sans prévision ça ne marche pas, et une heure saisie à
la main serait un pari présenté comme un réglage.

Le gain reste écrit dans la note, parce qu'il justifie que le moteur s'en occupe : pic PV de
midi libéré pour l'ECS et la voiture, moins de temps passé à 100 % donc moins de
vieillissement calendaire, et aucun kWh sacrifié — la même énergie, stockée plus tard.

La frise du point de non-retour n'est PAS supprimée : elle remonte dans l'encart du motif
commun, où elle illustre ce que les trois cas partagent au lieu d'illustrer un réglage. C'est
même sa vraie place — une prévision n'engage personne, un point de non-retour si.

Et l'encart gagne une distinction qui manquait : quels paramètres sont calculés (charge
différée → Héos) et lesquels sont saisis (charge réseau, échéance véhicule). Le motif tient
dans les deux cas, ce qui renforce l'argument : le moteur porte la forme, Héos ou
l'utilisateur la remplit.

L'écran de configuration batterie ne garde donc que quatre blocs : le nom, le rang en lecture,
la décharge pendant recharge VE, la réserve adaptative, et la charge réseau manuelle. Quatre
passes, quatre retraits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 21:25:41 +02:00
Patrick Schurig
77f29b3f3b docs(maquette): plus de permissions par charge — un seul interrupteur de décharge
Troisième passe de Patrick, et elle enlève encore. Le tableau « qui peut être servi par la
batterie » n'a pas lieu d'être : décider en général qui mérite d'être servi n'est pas un
réglage d'installateur, c'est un arbitrage — et il reviendra à Héos (l'optimiseur MILP) ou à
une décision par le prix, quand l'un ou l'autre existera. Offrir la question à la main
aujourd'hui figerait dans un écran ce qui doit se calculer.

Reste UN interrupteur : bloquer la décharge pendant la recharge du véhicule. C'est le seul
cas qu'aucun rang ne peut exprimer — la nuit il n'y a pas de surplus à répartir, donc aucune
position dans une file ne dit « ne vide pas la batterie dans la voiture ».

Le CSS des lignes de permission est retiré avec elles, plutôt que laissé mort dans la
feuille.

Trois passes, et l'écran n'a fait que rétrécir : un stepper de rang qui doublonnait la liste,
une colonne de permission qui doublonnait le rang, puis le tableau entier. Ce qui reste est
ce qu'aucun autre mécanisme ne sait dire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 21:18:51 +02:00
Patrick Schurig
47495e48f6 docs(maquette): le rang sort de l'écran batterie, et les permissions ne gardent que la décharge
Deux corrections de Patrick, dont une qui rattrape une incohérence de ma part.

1. LE RANG N'A RIEN À FAIRE SUR CET ÉCRAN. J'y avais mis un stepper « Position −/+ ». Aucun
   des six autres écrans de mécanisme n'en porte : le rang se règle par glisser-déposer dans
   la liste des charges, et l'écran réel (load_mechanism_screen.dart) ne fait qu'« Inclure
   dans l'arbitrage ». Deux réglages de rang auraient fini par diverger.

   Il devient une ligne de lecture, qui rappelle le défaut — batterie en première position de
   charge — et où il se règle vraiment.

2. « QUI A LE DROIT AVANT LA BATTERIE » N'EST PAS UN RÉGLAGE : c'est le rang. Mon tableau
   avait deux colonnes, Solaire et Batterie ; la première disait exactement ce que la
   position dit déjà. Retirée.

   Ce qui reste est d'une autre nature, et c'est le seul cas qu'aucun rang ne peut exprimer :
   la nuit, il n'y a pas de surplus à répartir, donc « ne vide pas la batterie dans la
   voiture » n'est pas une position dans une file, c'est une permission de source. Un seul
   interrupteur par charge : peut être servi PAR la batterie, ou non.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 21:12:13 +02:00
Patrick Schurig
aa22a52187 docs(maquette): « Configurer · Batterie » — septième écran, et un brief pour le moteur
La batterie devient une charge du waterfall, donc son bouton Configurer ouvre un écran de
mécanisme comme les cinq autres. Ajouté à la planche existante plutôt qu'à part : c'est
précisément la comparaison côte à côte qui avait révélé « six écrans, trois contrats » avant
qu'une ligne ne soit écrite.

RIEN DE CET ÉCRAN N'EXISTE. Ni BatteryAdapter, ni entrée LoadConfig pour le stockage, ni
prévision PV. Les seuls leviers réels de .75 sont les cinq actions de la classe SunSpec
Storage, et l'état réel affiché se limite à ce que le Thing publie — tout le reste porte un
badge « à créer », y compris là où ça alourdit la maquette.

Le rang par défaut est la batterie EN PREMIER, et ce n'est pas un choix de conception : c'est
la reproduction du comportement actuel. Une mise à jour ne change jamais l'arbitrage sans
geste ; la nouveauté est qu'il devient réglable.

Quatre blocs dans Configurer, dont deux qui ont demandé à être reformulés avant d'être
dessinés :

- Les blocages ne sont PAS une question de rang mais de SOURCE. « Ne pas vider la batterie
  dans la voiture la nuit » ne s'exprime pas par un ordre de priorité — la nuit il n'y a pas
  de surplus à répartir. La formulation juste est « quelles charges ont le droit d'être
  servies par la batterie », réglée charge par charge. Le contrat porte déjà
  Source { Solar, GridSource } ; il manque le troisième terme et le droit de le refuser.

- La réserve adaptative est montrée AVEC sa réserve : Victron a conçu Battery Life pour du
  plomb, et sur du LFP le problème traité — la sulfatation — n'existe quasiment pas. Si le
  mécanisme est repris, ce sera pour un motif à nommer, pas par mimétisme.

- La charge différée montre le POINT DE NON-RETOUR, pas la prévision. C'est lui qui garantit
  qu'une prévision fausse ne coûte rien.

- La charge réseau pose le mécanisme seul. Ni prix, ni Tempo, ni marché de gros : le jour où
  une source tarifaire existera, elle remplacera le doigt de l'utilisateur sans que le bloc
  ne change.

Le boost vers le véhicule est HORS Configurer, sur la carte du tableau de bord : c'est le
seul cas où la batterie est une source et non une charge, et c'est un geste immédiat, pas une
intention durable. Trois fins possibles, toutes obligatoires — une action qui ne sait pas
s'arrêter n'a pas sa place sur un écran client.

Et le motif que la planche fait ressortir : charge différée, charge réseau et échéance
véhicule (LM-1009) ont la MÊME forme — une cible, une échéance, un point de non-retour. Trois
colonnes côte à côte le montrent ; le moteur peut l'implémenter une fois.

Trois questions écrites pour le moteur, pas résolues ici : bloquer demande un interdit ACTIF
et non une allocation à zéro, puisque la batterie agit d'elle-même · cet interdit doit
EXPIRER, sinon un HEMS qui tombe laisse la batterie inerte · et controlHealthy == false doit
sortir la batterie de l'arbitrage, comme available == false pour une charge en défaut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 20:54:57 +02:00
Patrick Schurig
a1d0659db7 docs(brief): la question au moteur — le stockage est-il une charge arbitrable ?
Formulée depuis une demande simple de Patrick : que le surplus aille d'abord à l'eau chaude,
et à la batterie seulement ensuite. Elle n'est pas exprimable aujourd'hui, et le brief dit
pourquoi avec ce qui a été vérifié plutôt qu'avec ce qui est supposé.

Quatre constats : batteryLevelConsideration donne la priorité à la BATTERIE (le budget est
annulé sous le seuil, pas au-dessus) ; la batterie n'est dans aucun GetLoadConfig ;
enableCharging et ses quatre voisins ont zéro occurrence dans energyplugin/ ; et pourtant la
classe SunSpec Storage de .75 les déclare comme actions exécutables. Plus un indice :
domain: "battery" est déjà accepté par SetLoadConfig sans que rien n'en fasse quoi que ce
soit.

Quatre sous-questions, dont la première est la leçon de 3g-2 retournée vers eux : l'onduleur
Fronius a sa propre logique de charge, donc avant d'ajouter un commandeur il faut savoir qui
commande déjà. C'est littéralement le défaut d'adjustEvChargers(), et il serait absurde de le
réintroduire sur la batterie le jour où ils viennent de le supprimer sur les bornes.

Et ce que nous nous interdisons en attendant, dit explicitement : une règle Rules.* qui
couperait enableCharging fonctionnerait, et nous l'écartons — ce serait un second commandeur
sans arbitre. Mieux vaut dire « pas réglable aujourd'hui » qu'offrir un réglage dont personne
ne garantit l'effet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 19:58:54 +02:00
Patrick Schurig
01fdad73c5 fix(ergonomie): les cibles tactiles étaient à 26 px — trouvé au premier passage sur appareil
Le test d'appareil passe. Il a trouvé deux défauts réels que 129 tests unitaires ne
pouvaient pas voir, parce qu'aucun ne rend ces widgets à une taille d'écran réelle.

1. LA POIGNÉE DE GLISSEMENT MESURAIT 26 × 27 px, pour un minimum Material de 48. Le
   Container transparent posé lors de l'enquête précédente avait supprimé la bande morte
   entre l'icône et le reste, mais il n'avait jamais AGRANDI la cible : le geste accrochait
   quand on visait juste et ratait sinon. C'est le seul geste de cet écran sans repli —
   sans lui, l'ordre des domaines ne se change pas du tout.

2. LES FLÈCHES DE RANG MESURAIENT 18 × 18 px, la taille de l'icône, sans aucune marge de
   touche. Elles n'ont pas de repli non plus : réordonner DANS un domaine ne se fait que
   par elles.

Portées à 48 et 44. Et la capture d'appareil a montré le coût de la correction naïve :
empilées, deux cibles de 44 font 88 px de haut pour deux icônes de 18, et la colonne de
rang devient une colonne vide. Côte à côte, la hauteur retombe à 44 et la largeur ne coûte
rien au libellé. La cible tactile est la contrainte ; la disposition ne l'est pas.

Le harnais a lui-même demandé trois corrections avant de dire la vérité, et chacune est un
piège qui se reproduira :

- il regardait le HAUT de l'écran. Dans un ListView, ce qui n'est pas visible n'est pas
  construit : les cibles n'étaient pas mesurables faute d'être montées.
- `find` trouve un widget POSÉ MAIS HORS VIEWPORT, et getCenter en rend une coordonnée qui
  n'existe pas — mesuré y=1129 sur un écran de 825 px. Le geste partait dans le vide, avec
  un symptôme identique à un glissement ignoré.
- la course était devinée. Une liste réordonnable ne permute qu'une fois passé le milieu du
  suivant, et un bloc de domaine porte ses cartes : 384 px mesurés, contre les ~150 devinés.
  Elle se calcule maintenant depuis l'écart réel entre deux poignées.

Le test mesure aussi le défilement de la page pendant le glissement : si le ListView
extérieur gagnait l'arène, le symptôme serait le même que celui d'une poignée qui
n'accroche pas, pour une cause opposée. Mesuré à 0 px — c'est bien la poignée qui prend.

Aucun débordement de mise en page sur cet écran. Deux captures d'appareil jointes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 15:25:23 +02:00
Patrick Schurig
f33b8c243a feat(support): le rapport de bug pré-rempli, repoussé depuis le premier jour
/bug-report était un stub. Un champ libre seul produit « ça ne marche pas », et l'aller-retour
pour obtenir versions, charges et état d'arbitrage coûte des jours. Tout ce que l'app SAIT
est donc joint d'office : version du paquet, hôte et état de connexion, ligne de versions du
schéma, les charges déclarées avec adaptateur / rang / domaine, et le dernier cycle publié.

Deux choix qui ne sont pas cosmétiques :

- LES DEUX IDENTITÉS DU BUDGET sont dans le rapport, avec leur verdict. Elles valent souvent
  plus que la description du symptôme : portées fausses, elles désignent le moteur ; portées
  vraies, elles désignent l'écran. Sans elles, le premier échange consiste à les demander.

- Aucun secret n'y entre : ni PIN installateur même haché, ni jeton, ni mot de passe. Un
  rapport voyage — courriel, capture, fil de discussion — et ce qui y entre en sort. Les
  UUID de Things restent : ce sont eux qui permettent de recouper avec le journal de la box,
  et ils n'ont aucune valeur hors de l'installation.

Les absences gardent leur sens, comme partout ailleurs : « aucune télémétrie reçue » n'est
pas un arbitrage à zéro, « budget ABSENT » n'est pas un budget de zéro watt, et un
financement omis se dit omis.

Pas d'envoi automatique : il n'existe aucun service de collecte, et un bouton « Envoyer »
sans destinataire donnerait le sentiment que quelqu'un l'a reçu. Le rapport se copie, ce qui
est vérifiable.

package_info_plus est ajouté plutôt qu'une constante de version recopiée à la main : une
constante dérive de pubspec.yaml en silence, et un rapport qui annonce la mauvaise version
envoie chercher un défaut dans un code qui n'est pas celui-là.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 14:54:27 +02:00
Patrick Schurig
963ab98f13 docs: retour au moteur sur 3g-2 — deux vérifications, et une question sur la cible aveugle
Aller-retour neutre confirmé sur les quatre entrées, glissement sans cas particulier
confirmé, correctif de description constaté en place.

Deux choses à leur rendre : EV_GRID_START aurait cassé en silence la réconciliation posée
hier (une ligne à funding grid dont la part surplus n'est pas nulle), et il n'existe aucun
SOC de véhicule dans le contrat — donc targetPercentage est une cible sans mesure
d'avancement, qu'aucun écran ne peut suivre. La question leur revient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 14:34:50 +02:00
Patrick Schurig
f7fd91edde 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
2026-08-27 14:33:25 +02:00
Patrick Schurig
4b42b732dc docs: le contrat de recharge et les deux pièges de télémétrie, plus le retour au moteur
CLAUDE.md portait un exemple de SetChargingInfo qui invitait à l'écriture partielle. Il
porte maintenant la règle inverse, avec la preuve machine, et les deux pièges de lecture de
GetLoadTelemetry : ne pas sommer allocatedW sans filtrer sur funding, et ne pas lire
l'allocation d'une borne comme son état.

Retour au moteur dans BRIEF_plugin_depuis_maquettes.md. Un point actionnable chez eux : la
description trompeuse vit dans nymeaenergyjsonhandler.cpp:114 et part dans Introspect chez
tout client. Elle ne se contente pas de ne pas prévenir, elle autorise explicitement
l'erreur — le prochain client la croira. Une ligne à corriger, ou une écriture à rendre
réellement partielle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 07:39:12 +02:00
Patrick Schurig
e84f59d04a docs(brief): trois constats du moteur vers l'app, dont un retiré
1. SetChargingInfo remplace un targetPercentage absent par 80, EN SILENCE — réponse
   EnergyErrorNoError, aucun avertissement, et la seule trace au journal imprime déjà 80.
   L'objet est reconstruit entier à chaque écriture : un champ omis retombe sur son défaut,
   il n'est pas « inchangé ». L'app doit lire-patcher-réécrire, et ne pas afficher 80 comme
   une cible que l'utilisateur aurait choisie.

2. L'alerte sur SampleRate15Mins était FAUSSE et je la retire : ma sonde lisait
   currentPowerProduction là où les entrées de log portent production. Vérifié dans les deux
   sens — 51 échantillons non nuls par RPC, 7908 lignes en base. L'historique 90 jours n'est
   bloqué par rien. Le piège réel est le nom des champs, sans préfixe currentPower.

3. loads[] contient enfin les bornes (buildTelemetry les sautait) et porte un champ funding
   surplus/grid, sans lequel sommer allocatedW donne un écart ininterprétable. Avec la mise
   en garde qui va avec : adjustEvChargers() re-décide après le waterfall, donc pour une
   borne l'allocation publiée est une intention, pas un ordre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 18:43:54 +02:00
Patrick Schurig
15a52637a5 test(appareil): l'IU de configuration tourne sur le téléphone, contre .75
Chaîne complète vérifiée sur l'appareil : schéma lu, écran fusionné monté, charges réelles
affichées ([chauffe-eau, pac-terrain]) avec leur télémétrie, écriture confirmée par
LoadConfigChanged, restauration.

Le dernier obstacle était le plus instructif : /energy/setup est une route réservée au mode
installateur, et le routeur la renvoie vers / tant qu'il est verrouillé. Le symptôme était
« aucune charge lue » — on aurait cherché un défaut de lecture RPC là où le verrou faisait
son travail. C'est le contrôle de montage d'écran ajouté au harnais qui a tranché.

Le test déverrouille avec le PIN par défaut et reverrouille APRÈS la pause d'observation :
l'ordre inverse passait les assertions mais renvoyait l'écran au tableau de bord.

Relevé : docs/RELEVE_LOTC.md §2 et §6 mis à jour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 23:00:58 +02:00
Patrick Schurig
98e2874ecd test(appareil): diagnostic de montage d'écran + relevé — MIUI bloque l'installation USB
Le run rebranché est allé plus loin que jamais : connexion à .75 et lecture du schéma sur
l'appareil, puis blocage sur la lecture des charges. Un contrôle de montage de l'écran est
ajouté pour distinguer « navigation non faite » de « lecture en échec » — mais il n'a pas
pu s'exécuter : MIUI a remis à zéro « Installer via USB » à la reconnexion et refuse
désormais toute installation (INSTALL_FAILED_USER_RESTRICTED, sans dialogue). L'app n'est
de ce fait plus installée sur le téléphone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 22:48:09 +02:00
Patrick Schurig
f0aa0fd804 test(appareil): harnais integration_test — l'IU sur le téléphone, contre la vraie box
adb shell input tap est refusé par MIUI (INJECT_EVENTS). integration_test contourne le
problème par construction : le test s'exécute DANS le processus de l'app et tape sur les
widgets depuis l'intérieur, sans injection d'événements Android.

Ce que le harnais a déjà prouvé SUR L'APPAREIL, contre .75 : l'app démarre, se connecte
(« connecté à 1.15.2+202606191336~trixie1 ») et SchemaProvider lit JSONRPC.Introspect
(« NymeaEnergy 0.8 · AirConditioning 1.1 »). Le grisage dérivé du schéma n'est donc pas
une théorie : il est alimenté sur le téléphone par la vraie box.

Le run n'est pas allé au bout. Trois obstacles : GoRouter.of() cherché au-dessus du Router
(corrigé) ; le test supposait une installation déjà enregistrée (corrigé — il enregistre la
box lui-même via connectNew) ; puis le téléphone s'est déconnecté du bus USB, et l'ADB sans
fil n'est pas activé. C'est là que ça s'est arrêté.

EFFET DE BORD ASSUMÉ : flutter test integration_test RÉINSTALLE l'app, ce qui vide
shared_preferences et le stockage sécurisé — les deux installations enregistrées sur le
téléphone ont été effacées. Le harnais ne dépend plus de cet état et restaurera .75 au
premier run mené à terme ; nymea-dev-kutz est à rajouter à la main.

Relevé mis à jour : docs/RELEVE_LOTC.md §2 et §6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 22:42:09 +02:00
Patrick Schurig
2a8ad95120 feat(config): lot C — un seul écran de configuration, et le grisage vient du schéma
FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE
DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils »,
jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une
PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un
canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de
GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter).

La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une
échéance » est retirée : aucun de ces comportements n'existe dans le moteur.

ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié
sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer
d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous
réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel.
L'écran le nomme, avec la durée de réarmement.

GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et
non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le
schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant.
La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15
n'est exposé par aucune méthode.

CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait
EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent
au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun
champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de
plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box
accepte, et le refus remonte sans en inventer la cause.

ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning
publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont
des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le
vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone.

Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu
être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 22:17:16 +02:00
Patrick Schurig
eac1dde4ee docs: recalage sur la rév. 3 du contrat charge pilotée
Le miroir INTERFACE_etmvariableload.md est passé en rév. 3 pendant la session (corps
identique au canonique du dépôt plugin, vérifié). Son §10 s'adresse nommément à l'agent
app — et il RATIFIE les maquettes dessinées aujourd'hui : relays[] = {thingId, powerW}
pour l'ECS relais, maxPowerW seul pour le continu, powerLevels absents de la config et
dérivés par le routeur.

Trois recalages :

- Le picker de relais filtre sur l'interface nymea « power » et présente les things par
  NOM — nommé dans la maquette, il ne l'était pas.
- Le point 4 du brief plugin est tranché par la rév. 3 : la duplication de la
  combinatoire est voulue (l'app dérive pour montrer, le routeur pour exécuter). La
  demande devient donc un moyen de détecter la divergence — la télémétrie publie déjà
  maxStageW, l'app peut y confronter le maximum de sa propre liste.
- En-tête du pont VM Service, que le commit précédent avait laissé dans sa forme brute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-25 21:45:53 +02:00
Patrick Schurig
054cbfab3c docs: maquettes de la refonte « Charges pilotables » + CLAUDE.md recalé sur la box
Trois maquettes, neuf écrans, après le passage sur appareil qui a montré que
/energy/setup décrit autre chose que l'installation.

- charges_pilotables_mockup.html : la liste à deux niveaux, avec le bandeau
  d'arbitrage hérité de /energy/loads (qui disparaît) et la télémétrie sur la carte
  de chaque charge.
- configurer_mecanismes_mockup.html : les six « Configurer » — routeur de relais,
  modulable, SG-Ready, borne, zones HVAC, sélecteur d'appareils.
- raccordement_reseau_mockup.html : compteur réseau et protection de surcharge.

Décisions de conception que les images ne portent pas seules : les paliers
n'existent que chez le routeur de relais et y sont DÉDUITS des combinaisons ; le
mode fixed d'etmvariableload (legacy) n'est plus proposé mais reste lisible, parce
qu'ouvrir un écran ne doit pas convertir une config déployée ; SG1/SG2 sont une
convention d'app retrouvée par l'encodage standard, la box ne stocke qu'une liste
de relais par état ; le compteur par charge est dessiné inactif avec sa raison ; la
borne règle un mode par DÉFAUT, pas une commande.

CLAUDE.md corrigé contre JSONRPC.Introspect (plugin 1.15.2+etm15) :

- Energy.SetChargingMode n'existe pas — le « bug critique » décrivait un appel
  impossible. Energy.* n'a que cinq méthodes.
- Le namespace EnergyPlugin.* n'existe pas : c'est NymeaEnergy.*, 20 méthodes et
  14 notifications, toutes listées.
- ChargingInfo : chargingMode / targetPercentage / endDateTime (Uint), et non
  mode / targetSoc / endTime. minCurrent n'existe nulle part.
- phasePowerLimit est en AMPÈRES par phase, et 0 coupe toute la recharge
  intelligente.
- AirConditioning expose 8 méthodes, pas 3.

Toutes les méthodes citées ont été revérifiées une par une : aucune introuvable.
Règle ajoutée : contrôler ce fichier contre Introspect avant chaque lot — trois
fois aujourd'hui il a envoyé un agent dans le vide.

Et docs/BRIEF_plugin_depuis_maquettes.md : les six points que les maquettes
remontent vers le moteur, dont un seul blocage dur (pas de champ de courant
minimum pour le mode Min+PV).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-25 21:30:32 +02:00
Patrick Schurig
0ebbde49da chore(docs): range specs/contrats/refs dans docs/, track l'autorité
- specs -> docs/ (DASHBOARD_SPEC compagnon du contrat)
- ref introspection nymea-jsonRPC -> docs/reference/
- mockups HTML -> docs/mockups/ ; briefs consommés -> docs/archive/
- track AGENTS.md + INTERFACE_etmvariableload.md (étaient untracked)
- INTERFACE_*: marqué miroir, canonique = repo plugin
2026-06-28 12:49:57 +02:00
Patrick Schurig
f755c727ee docs: contrat de données UI rev.5 — réserve décision-2 levée (cumuls séparés)
Intègre la vérif terrain (sonde .75) : GetPowerBalanceLogs sépare import/export
en cumulé (totalAcquisition / totalReturn) → autoconso/autonomie dérivables des
logs, la réserve de la décision-2 est levée. Aligne le doc tracké sur le code
(seam EnergyRatiosInterim, commit 3821bf3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:05:46 +02:00
Patrick Schurig
b5abe42fcc docs: contrat de données UI (rev.4) — qui calcule quoi
Source de vérité data-ownership UI, compagnon de DASHBOARD_SPEC.md.
Décisions figées rev.4 : flow §1.1 (Héos centre = métaphore, sens/couleurs),
ratios non calculés côté app (§2 → state energymanager), Things hand-off +
ModbusRTU d'abord, série canonique Héos Route B (§7.3), persistance Influx
(brut stocké + ratios dérivés + plan tagué run_id, §8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:07:16 +02:00
etm
d5dc0c7ca5 Initial commit : Flutter app nymea energy monitor
- NymeaService : auth complète (Hello → Authenticate → SetNotificationStatus)
- Token top-level dans chaque requête JSON-RPC (fix critique GetThings)
- Persistance token via shared_preferences par hôte
- Dashboard : champs utilisateur/mot de passe dans le dialog de connexion
- ThingDetailScreen : renommer, réglages (settingsTypes) et supprimer
- NymeaThingClass : champ settingsTypes parsé depuis l'API
- NymeaThing : copyWith(name) + settingValue()
- Fix overflow _StateChip dans ThingsScreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 16:57:46 +01:00