# Brief agent app — ce que 3g-2 change pour l'écran **É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. > Six points. Le sixième répond à vos quatre constats du 28 août. > Le deuxième **retire une mise en garde** que vous portiez — du code conditionnel > peut disparaître. Le cinquième répond à **votre** question sur `targetPercentage`, et c'est > celui qui change un écran : l'avancement s'affichera en énergie livrée, pas en pourcentage. --- ## 1. `loads[] ⊆ GetLoadConfig` est RÉTABLI 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. **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 : ``` 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 ``` 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é. **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 ». --- ## 2. ⚠️ La mise en garde sur `allocatedW` des bornes TOMBE 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. **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. **`+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. --- ## 5. `targetPercentage` — ta question est tranchée, et elle change ton écran Tu avais remonté que `targetPercentage` est une cible sans mesure d'avancement : aucune classe `evcharger` ne déclare d'état de charge, aucune ne porte d'interface véhicule. **C'est exact** — vérifié sur les cinq classes du banc. **Et le code fait pire que « il manque une mesure ».** `targetPercentage` est comparé à `carBatteryLevel`, lu sur le Thing *voiture*. Or dans le seul cas où ce mécanisme s'active — une voiture manuelle, dont `batteryLevel` est **inscriptible** — c'est **le moteur qui écrit cette valeur** : il intègre la puissance de la borne, applique un facteur de pertes, divise par une capacité déclarée, et réinjecte le résultat dans l'état du Thing. Il compare donc sa cible à sa propre estimation, bâtie sur trois grandeurs dont aucune n'est mesurée. **Ce que ça te coûte aujourd'hui** : l'estimation sort par la frontière RPC sous le nom `batteryLevel`, **indistinguable d'une mesure**. Un écran qui affiche « 62 % » affiche une intégration. Tu mens de bonne foi, et rien dans la charge utile ne te permet de t'en apercevoir. ### Les décisions (Patrick, 2026-08-27) — `specs/spec_loadmodel.md` LM-1009 **D1 — L'avancement s'affiche en ÉNERGIE LIVRÉE, jamais en pourcentage.** « 8,4 kWh livrés depuis le branchement » est vérifiable ; « 62 % » suppose une capacité que personne ne connaît. Le pourcentage garde sa place dans la **saisie de l'intention** — l'utilisateur pense sa voiture en pourcentage — mais **sort de l'affichage de l'avancement**. **D2 — Obligation par SESSION d'abord.** La source est `sessionEnergy`, l'énergie livrée depuis le branchement, publiée par la borne. C'est aussi l'usage réel : on branche le soir pour partir le matin. L'obligation *quotidienne* (`minEnergyWhPerDay`) est un lot à part, qui attend l'historique du §11 — ne l'anticipe pas dans l'écran. **D3 — L'estimation sera ÉTIQUETÉE à la publication.** Tant que le chemin `carBatteryLevel` survit, il ne franchira plus la frontière RPC sous le même nom qu'une mesure. Le précédent existe déjà côté moteur : `estimatedPowerW` porte son préfixe exprès. ### Ce que ça implique pour toi, concrètement - **Deux régimes de vérité, et il faut pouvoir dire lequel s'applique** (condition C2 de la spec) : une obligation en **énergie** peut annoncer son atteinte ; une cible en **pourcentage** publie une intention et un avancement *estimé* — **jamais « tenue »**. Un avancement mesuré et un avancement estimé ne se distinguent par aucune de leurs valeurs : les deux sont un nombre qui monte. Si le régime ne se lit pas, le second se fait passer pour le premier. - **Certaines bornes ne publient pas `sessionEnergy`** (condition C1) — mesuré : `wallboxNoMeter` et l'une des deux classes `wallbox`. Sur celles-là l'avancement n'est pas mesurable, et **l'écran doit le montrer** : une échéance qui cesse silencieusement d'être mesurable est pire qu'une échéance absente, parce qu'elle continue de s'afficher. Surtout, ne pas afficher zéro — « pas mesurable » et « rien livré » sont deux états différents. - **Rien n'est encore codé côté moteur** : `sessionEnergy` n'est lue nulle part et `LoadContextTelemetry::sessionWh` est déclarée mais jamais remplie. Le §10 le fera. Ce brief te donne la forme d'arrivée pour que tu ne bâtisses pas sur le pourcentage entre-temps. --- --- ## 6. Réponses à vos quatre constats du 2026-08-28 ### A — la conclusion est retenue, le mécanisme ne l'est pas **Votre conclusion est juste, elle est adoptée, et elle est LIVRÉE** (`spec_loadmodel.md` **LM-1208-b**, moteur `1.15.2+etm25`) : « pas de voiture assignée » cesse d'être un critère d'exclusion. Vos trois arguments tiennent, et le troisième est décisif — **le critère contredit LM-1206**, qui donne le rang et le plancher à la borne et non au véhicule. **Mais le budget n'était pas faussé, et c'est votre propre banc qui le montre.** Le budget part de la mesure **brute** du compteur (invariant 8) : la consommation d'une borne exclue y est déjà déduite. Journal du 28 à 10:49, pendant que la V2C tirait : ``` Current load on root meter: 3531.4W (avant : −2915 W, en export) [Arbitre] "chauffe-eau" → "Surplus insuffisant (−3531 W) — chauffe-eau à 0 W" ``` **L'ECS n'a pas été servi par-dessus** — l'arbitre a vu l'import et a refusé. L'exclusion est *conservatrice* : la charge exclue retire du budget aux autres, et personne ne le lui rend. > **Votre règle générale est néanmoins retenue**, parce qu'elle a un porteur étroit et réel : une > borne exclue du contexte mais **commandée par le proxy** ne reçoit aucun `evReservedW`. Sa > puissance commandée et pas encore mesurée n'est réservée par personne. Transitoire — un cycle — > et sans effet aujourd'hui, mais **elle mordra au §10**, où la passe éco décide d'acheter. > **Ce qui change pour vous, concrètement** : une borne en mode éco, véhicule branché, **sans > voiture déclarée**, est désormais arbitrée — elle reçoit une enveloppe, un rang, et un motif > publié dans `GetLoadTelemetry` comme n'importe quelle autre charge. Ce qu'elle n'a pas : > l'échéance et la cible d'énergie, qui restent au proxy et supposent un véhicule connu. C'est > une **fonction dégradée**, à montrer comme telle — pas une borne en défaut. > > **Et une conséquence trouvée en implantant, qui vous concerne** (`LM-1208-c`) : sans voiture, > le moteur ne connaît pas la capacité du véhicule, donc il **ne bascule plus les phases** et > retient le `phaseCount` de la borne. Une borne triphasée reste arbitrée en triphasé — c'est > volontaire et conservateur pour le budget. La bascule 1↔3 revient dès qu'une voiture est > déclarée. **⚠️ Un écart de MESURE mérite votre attention, et il est distinct** : le compteur racine lit **3 250 W** quand la V2C annonce **3 980 W** à elle seule (plus la PAC à 800 W). Environ 1,5 kW que le compteur racine ne voit pas. Ce n'est pas un problème d'arbitrage — c'est une question de câblage ou de vue SunSpec, de la même famille que l'aveuglement du compteur aux résistances ECS documenté dans `AGENTS.md`. **À investiguer pour lui-même** : un budget juste suppose un compteur qui voit tout. ### B — même cause, mais le mode manuel n'est pas en cause Corriger A ne suffira pas : la V2C est en **`ChargingModeNormal`**, et le mode manuel reste un critère d'exclusion **délibéré** — l'utilisateur pilote, l'arbitre s'abstient. **Le vrai point est ailleurs, et il vous concerne** : le mode par défaut d'une borne fraîchement détectée **est** `ChargingModeNormal`. Une borne neuve n'est donc jamais arbitrée tant que personne n'a choisi un mode éco. C'est correct par construction, mais **invisible** — et « la borne ne charge pas toute seule » se lira comme un défaut. C'est un geste de mise en service à montrer, pas un correctif moteur. ### C — vous pouvez déjà la retirer, aujourd'hui, sans rien ajouter L'entrée résiduelle de la Terra AC **se supprime depuis l'app** : `SetLoadConfig` **remplace l'ensemble**, donc renvoyer la liste sans cette entrée la retire. Et elle ne sera **pas recréée** : l'auto-provisionnement ne tourne que pour les bornes présentes dans nymea, et le Thing a été supprimé. C'est exactement le « geste, pas une suppression d'office » que vous demandez, et il existe déjà. > **La limite à connaître** : si vous supprimez l'entrée d'une borne dont le **Thing existe > encore**, elle sera recréée au cycle suivant — c'est l'auto-provisionnement, et c'est voulu. > Retirer une borne de l'arbitrage sans supprimer son Thing se fait par `enabled: false`, pas par > suppression. ### D — instruction reçue, rien ne bouge côté moteur Aucun cas particulier V2C dans le moteur : le contrat reste celui des autres bornes, et **C1 de LM-1009 est maintenue** — d'autres bornes n'auront ni `sessionEnergy` ni valeur accumulable. Votre distinction est plus fine que ce que le brief avait anticipé et elle est notée : **un champ absent se voit ; un champ qui s'efface se lit comme un zéro.** C'est un troisième régime, entre « mesuré » et « pas mesurable » — et c'est précisément pourquoi C2 exige que le régime soit *publié* et non déduit d'une valeur. Et `targetPercentage = 80` observé sur la V2C **confirme sur machine** le défaut corrigé en `+etm23` : le champ omis retombait à son défaut. Ce n'est plus une déduction. --- ## 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.