Patrick Schurig be401dec40 fix(dérogation): une borne n'est pas écrêtée deux fois, et un mode écrit la termine
Deux défauts trouvés par le premier essai au banc (2026-09-08, 11:05→11:16),
et le premier est celui que Patrick a vu en regardant la voiture.

LM-1500-h — UNE BORNE N'EST PAS ÉCRÊTÉE DEUX FOIS.

La tête de passe éco appliquait `margeRestante` au plafond d'une borne. Or
`margeRestante` est la marge d'UNE phase, et le plafond d'une borne triphasée
est une enveloppe sur TROIS : les comparer divise son allocation par trois.
Mesuré : marge de 6 210 W sur la phase la plus chargée, plafond de cycle à
13 800 W, borne servie à 6 210 — 9 A par phase au lieu de 27, 4 kW au lieu des
11 que la voiture pouvait prendre.

Le plafond d'une borne porte DÉJÀ l'autorisation de soutirage :
evPhaseAllowanceW() la calcule et la multiplie par le nombre de phases
occupées. La passe confort ne fait pas cette faute, et son commentaire dit
pourquoi depuis 3g-2 — « mieux vaut la borne mieux informée que la borne la
plus sévère ». Je l'ai réécrite quinze lignes plus haut sans la relire : une
règle notée à un seul endroit du code n'est pas une règle.

L'erreur allait dans le sens PRUDENT, donc rien ne la signalait — ni le
disjoncteur, ni le délestage, ni un test qui vérifie qu'on ne dépasse pas. Elle
ne se voyait qu'en regardant la voiture.

LM-1500-g — ÉCRIRE UN MODE PENDANT UNE DÉROGATION LA TERMINE.

Choisir un mode est durable ; une dérogation répond à un besoin ponctuel. Le
durable prime, et il prime tout de suite. Et SANS restituer le mode mémorisé,
qui écraserait celui que l'utilisateur vient de choisir.

Le cas vient du banc : le bouton « boost » de l'app écrit encore
ChargingModeNormal. Reçu pendant une dérogation, il fait restituer les valeurs
manuelles par l'amont — chargingEnabled à FAUX — et arme un verrou de 300 s,
pendant que la dérogation continue de commander 9 A. Deux commandeurs pour un
organe, et six minutes de dérogation servie sur le papier dans une borne
éteinte. Seul le journal de divergence le voyait, sans pouvoir en dire la cause.

Deux autres corrections du même essai :

- DEROGATION_ACTIVE et DEROGATION_ENDED sont rendus en français au journal ;
  ils sortaient en « (motif inconnu …) ». Le repli 7-c a tenu — le code brut et
  ses paramètres, jamais une chaîne vide — mais la ligne lue par l'utilisateur
  était illisible.
- poseDerogation() prend l'instant en paramètre. Elle lisait l'horloge murale
  quand le cycle lit l'horloge injectée : sans effet en production, où les deux
  sont la même, mais sous test la fin par durée n'arrivait jamais. Trouvé par le
  test, pas par la lecture.

Et `setChargingInfo() déclenche un cycle` est gravé dans AGENTS.md comme une
PROPRIÉTÉ : ce qui l'entoure a un ordre qui compte, dans les deux sens, et
ENERGY_SIMULATION supprime précisément l'appel — le chemin fautif n'existe pas
sous test.

simulation 69/69, loadmodel 26/26, charging 48/48, spotmarket 32/32,
ci-quality 4/4, build 0 erreur 0 warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 11:43:39 +02:00

2219 lines
157 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

powersync-energy-plugin-nymea (1.15.2+etm61) trixie; urgency=medium
* LM-1500-h — une BORNE n'est pas écrêtée deux fois. La tête de passe éco
appliquait margeRestante, qui est la marge d'UNE phase, au plafond TRIPHASÉ
d'une borne : 6 210 W au lieu de 13 800, soit 9 A par phase au lieu de 27 —
4 kW au lieu des 11 que la voiture pouvait prendre. Le plafond de cycle
d'une borne porte DÉJÀ l'autorisation de soutirage, multipliée par le nombre
de phases occupées (evPhaseAllowanceW). La passe confort ne fait pas cette
faute et son commentaire dit pourquoi depuis 3g-2. Erreur du côté prudent,
donc invisible du disjoncteur, du délestage et de tout test de dépassement.
* LM-1500-g — écrire un MODE pendant une dérogation la termine (cause
modeChanged), et SANS restituer le mode mémorisé, qui écraserait celui que
l'utilisateur vient de choisir. Constaté au banc : le bouton « boost » de
l'app écrit encore ChargingModeNormal ; reçu pendant une dérogation, il
faisait restituer les valeurs manuelles (chargingEnabled à faux) et armait
un verrou de 300 s pendant que la dérogation commandait 9 A — deux
commandeurs pour un organe, six minutes de charge nulle servie sur le papier.
* Les motifs DEROGATION_ACTIVE et DEROGATION_ENDED sont rendus en français au
journal ; ils sortaient en « motif inconnu ».
* poseDerogation() prend l'instant en paramètre : elle lisait l'horloge murale
quand le cycle lit l'horloge injectée, et la fin par durée n'arrivait jamais
sous test.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 08 Sep 2026 11:35:34 +0200
powersync-energy-plugin-nymea (1.15.2+etm60) trixie; urgency=medium
* Dérogation : deux effets de setChargingInfo(), qui déclenche lui-même un
update(). À la pose, l'écriture du mode vient EN DERNIER — le cycle qu'elle
déclenche trouve alors la dérogation déjà posée et la sert immédiatement, au
lieu de la manquer d'un cycle. À la fin, la restitution du mode est DIFFÉRÉE
d'un tour de boucle : les fins sont évaluées depuis update(), et y écrire le
mode imbriquait un update() dans un update() — deux plans et deux dispatchs
pour le même instant, sans qu'aucun verrou de ré-entrance le signale.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 08 Sep 2026 10:52:03 +0200
powersync-energy-plugin-nymea (1.15.2+etm59) trixie; urgency=medium
* Le BOOST, implémenté comme une DÉROGATION et non comme un mode. Elle se
superpose au mode courant, sert la charge à son maximum en tête de passe
éco — devant toute autre charge, quel que soit son rang — financée au
réseau, et s'efface d'elle-même. Deux verbes : SetLoadDerogation et
ClearLoadDerogation ; la durée est OBLIGATOIRE et bornée [60 s, 6 h]
(LM-1500 : une dérogation qui ne sait pas dire quand elle finit ne devrait
pas exister). Quatre fins par mécanisme — cible atteinte, mesure perdue,
débranchement, durée — plus « cancelled », publiées dans derogationEnded[]
au niveau de la trame : au débranchement, la charge n'est déjà plus dans
loads[]. Le mode précédent est MÉMORISÉ et restitué (LM-1500-d) ; une borne
en manuel est basculée en éco pour la durée, seul cas où le mode est écrit.
DRAW_CAP la borne et le délestage la reprend : elle prend la tête de la
passe éco, elle ne surplombe pas la sûreté (LM-1500-e).
* La fin par MESURE ne mord que s'il y a une cible (LM-1500-b), et son critère
est telemetry.sessionMeasurable et non progress.regime : `progress` est
absent d'une charge sans obligation en énergie — exactement le cas du boost,
où le critère aurait été inerte.
* §15 du modèle de charge : LM-1500 et ses six déclinaisons.
Simulation::testDerogationTakesTheHeadButNotTheSafety prouve la tête de
passe par la RARETÉ — 7 500 W d'autorisation pour 8 000 demandés, la borne
de rang 9 servie entièrement et l'ECS de rang 1 réduite au reste.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 08 Sep 2026 10:29:40 +0200
powersync-energy-plugin-nymea (1.15.2+etm58) trixie; urgency=medium
* LM-1004 corrigée : le recrédit anti-clignotement appartient à la passe qui
DÉPENSE DU SURPLUS, pas à la « première passe où la charge apparaît ». La
passe éco est bornée par l'autorisation de soutirage et ne touche jamais
remainingSurplusW : lui attribuer le recrédit revenait à ne le créditer nulle
part.
* Conséquence levée : une charge servie en éco récupère de nouveau sa propre
consommation. « Minimum + surplus » sert enfin les deux — mesuré, la borne se
privait de 751 W de surplus qu'elle mangeait elle-même.
-- ETM Schurig <dev@etm-schurig.eu> Mon, 07 Sep 2026 13:10:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm57) trixie; urgency=medium
* « Surplus + minimum réseau » (LM-1013-b-vi) : le mode
ChargingModeEcoWithMinCurrent sert enfin un minimum. La passe éco rend
max(cadence de l'obligation, minimum), et le minimum ne se tient jamais —
quand l'obligation est tenue, la consigne retombe sur lui, pas sur zéro.
* Pour une borne, la valeur se DÉRIVE de maxChargingCurrent.minValue ×
phaseCount × 230 V à chaque cycle. Jamais stockée : une copie écrite à
l'appairage se périmerait au premier recâblage.
* Le motif ECO_FLOOR_GRID porte minAlwaysW et boundBy, et OMET les grandeurs
d'obligation sur une charge à minimum seul — un remainingS à 0 inventerait
une échéance imminente.
* Contrat : les trois descriptions de ChargingMode corrigées. Normal est
MANUEL, pas un boost — le moteur ne commande rien, seule la protection de
surcharge la touche encore.
* Les quatre instants stockés sont ancrés en UTC à la capture. Un QDateTime
local résout son époque à la conversion : measuredSince et command.since
dérivaient d'une heure au changement de fuseau.
-- ETM Schurig <dev@etm-schurig.eu> Mon, 07 Sep 2026 08:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm56) trixie; urgency=medium
* Divergence commande/mesure JOURNALISÉE à l'ouverture et à la fermeture
(qCInfo). Elle n'était que publiée dans la télémétrie et tenue dans un QHash
en mémoire : rien ne comptait les épisodes, le hash était purgé dès que
l'écart cessait, et chaque redémarrage effaçait l'état. Le silence du journal
était garanti par construction.
* GetEnergyRatios / EnergyRatiosChanged publient trois champs de plus :
periodStartedAt (epoch du minuit local), measuredSince (epoch de pose de la
baseline) et timeZone (IANA). Toujours présents, même quand les deux taux
sont omis.
* periodStartedAt ≠ measuredSince : la baseline se repose à chaque redémarrage
de nymead, donc en plein jour. Sans la paire, l'écran écrit « aujourd'hui »
sur des chiffres qui ne parlent que de l'après-midi.
* Le schéma des ratios est désormais déclaré UNE fois (energyRatiosSchema),
servi au Get et au Changed — invariant 11.
-- ETM Schurig <dev@etm-schurig.eu> Sat, 05 Sep 2026 09:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm55) trixie; urgency=medium
* §10 / LM-1013-b-v : sous le régime `unmeasurable`, la passe éco commande à
TAUX CONSTANT — cible ÷ jour — au lieu de l'étalement `reste / temps restant`.
Sous ce régime le numérateur est figé à la cible par contrat : un rapport dont
seul le dénominateur décroît ne suit plus un retard, il suit l'horloge.
* `atMaximum` n'est plus jamais vrai sous ce régime : le drapeau annonce que
l'étalement a cessé, et sans étalement il n'a rien à annoncer.
* Chiffré : obligation 4 kWh, plafond 7 kW, fenêtre d'un jour — l'ancienne loi
achetait 19 010 Wh, soit ×4,75 l'obligation, le facteur suivant ln(T·P/E)+1.
* Le régime mesuré est INCHANGÉ : durcissement et point de non-retour intacts,
et une contre-épreuve du test le vérifie au même instant et sur le même
numérateur.
-- ETM Schurig <dev@etm-schurig.eu> Fri, 04 Sep 2026 17:15:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm54) trixie; urgency=medium
* +etm53 DÉPLOYÉ sur .75 le 2026-09-04, version relue et `nymead` actif : `bindingPhase` et le
correctif amont de la tension sont sur la box.
* MOCK — LE COMPTEUR INTÈGRE ENFIN. `totalEnergyConsumed` n'était écrit par personne : il
valait sa valeur par défaut indéfiniment. Un compteur figé est un compteur qui N'EXISTE PAS
pour tout ce qui l'intègre — `deliveredWh` restait à 0, `remainingWh` à 4 000, et l'étalement
adaptatif n'était JAMAIS exercé. Le comportement se confondait avec `unmeasurable` pour une
raison entièrement différente et bénigne. QUATRIÈME occurrence d'un mock plus homogène que la
réalité.
* EN EXPORT IL NE RECULE PAS, il ne bouge pas : un compteur de consommation est monotone, et
le faire reculer produirait un cas que le terrain ne produit pas — celui que la garde de
sémantique traite comme une anomalie. Le mock fabriquerait le défaut que le moteur détecte.
* LM-1014-b APPLIQUÉE AU MOCK, question posée et répondue : `totalEnergyConsumed` est déclaré
`cached`, donc il CONTINUE au redémarrage. Repartir de zéro ferait reculer un cumul monotone.
* CONTRAT — `purchasedWh` gagne un SECOND USAGE, sous `measured` cette fois, et il n'est pas
redondant. Mesuré : il accumule (0 → 9,15 → 18,33 → 27,52 Wh) pendant que `deliveredWh` peut
rester PLAT. Cette divergence est un contrôle que l'écran n'aurait pas su fabriquer : de
l'acheté qui monte avec du livré plat signifie soit que la charge ne consomme pas ce qu'on
paie, soit que le compteur ne compte pas. Écrit parce que le jour où les deux coïncideront,
quelqu'un jugera le champ redondant — or c'est précisément quand ils DIVERGENT que la paire
devient informative, et un champ qui ne sert que dans l'anomalie n'a pas l'air utile le reste
du temps.
* Le test du cumul a une borne basse LARGE, et c'est délibéré : il ne contrôle ni la cadence du
minuteur ni l'alignement du premier tic, qui n'intègre rien. Une borne serrée en ferait un
test instable — c'est-à-dire un test qu'on relancerait jusqu'à ce qu'il passe.
* Suite : simulation 66/66, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Fri, 04 Sep 2026 14:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm53) trixie; urgency=medium
* [upstream-fix] rootmeter — LA TENSION EST DÉSORMAIS LOCALE À CHAQUE PHASE. `voltage` était
déclarée une seule fois hors des trois blocs et mutée par l'un d'eux : une phase sans tension
déclarée était calculée avec celle d'une autre.
* ET LE DÉFAUT N'ÉTAIT PAS CELUI QU'ON CROYAIT. L'interface `energymeter` rend `currentPhaseA`
et `voltagePhaseA` OBLIGATOIRES, B et C optionnels : la branche « puissance ÷ tension » de A
est MORTE PAR CONSTRUCTION. La fuite n'allait donc pas de A vers B, comme la note d'origine
l'affirmait, mais de B vers C. Plus étroit, et bien réel.
* DÉMONTRÉ : B à 200 V, C à 4 000 W sans tension déclarée → 5,0 A de marge annoncés au lieu de
7,6. Deux conditions, dont aucune n'était évidente avant d'écrire le test : le masque doit
couvrir B ET C (interroger C seule n'exécute pas le bloc de B), et C doit être la phase la
plus contrainte, sinon le `qMin` retient B et masque l'écart.
* MOCK — classe `meterPowerOnly` : puissances par phase et tension de B, SANS les courants de
B/C ni la tension de C. Sans elle le défaut restait inatteignable. TROISIÈME fois qu'ouvrir
le mock révèle un défaut plutôt que de seulement débloquer un test, après `dryRelay` et après
la correction du raisonnement sur l'écrêtage de la marge.
* `draw.bindingPhase` — LAQUELLE des phases borne. `perPhaseBound` disait qu'une phase borne,
jamais laquelle : l'écran ne pouvait dire que « bridé » sans dire par quoi, et enverrait
mesurer au hasard sur trois phases dont deux vont bien. Le motif DRAW_CAP la porte aussi,
mais il n'est émis que si une charge est REFUSÉE — un cycle qui borde sans rien refuser
laisserait l'écran muet. Les deux endroits ne se recouvrent pas.
* CONTRAT — une installation qui EXPORTE peut être bridée, et l'écran ne doit jamais présenter
une marge globale ni la meilleure phase : c'est la PIRE qui décide. Contre-intuitif au point
qu'un installateur cherchera l'erreur ailleurs.
* AGENTS.md — une conclusion JUSTE posée sur un raisonnement FAUX survit jusqu'à ce qu'on
bâtisse dessus, parce que la conclusion PROTÈGE le raisonnement de l'examen : un résultat
faux se voit, un résultat juste ferme la question. Deux occurrences de deux côtés de la
frontière. Parade : quand une décision est écrite avec sa raison, la raison mérite son propre
contrôle — un test qui vérifie la conclusion ne vérifie pas le raisonnement, et c'est le
raisonnement que le lot suivant utilisera.
* Suite : simulation 65/65, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Fri, 04 Sep 2026 11:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm52) trixie; urgency=medium
* MOCK — tension et courant PAR PHASE réglables indépendamment des puissances. Le mock posait
`currentPhaseX = currentPowerPhaseX / 230` : les deux familles d'états étaient cohérentes PAR
CONSTRUCTION, si bien qu'aucun scénario ne pouvait produire une triphasée déséquilibrée ni
une tension ≠ 230 V. Paramètres OPTIONNELS : sans eux, le comportement d'origine est conservé
et aucun scénario existant ne bouge.
* LM-1210-a ENFIN EXERCÉE — le choix de la pire phase faute de masque était écrit, raisonné, et
jamais vérifié. Phase B saturée à 30 A sous un plafond de 25 pendant que A et C exportent, et
la maison exporte GLOBALEMENT : la marge retenue est celle de B, négative, et non celle du
total qui rassurerait à tort. Ce manque était nommé DEUX FOIS par deux chemins indépendants —
l'inventaire du mock et le balayage des contrôles — avant d'être comblé.
* ET L'OUVERTURE A CORRIGÉ UNE ERREUR DE RAISONNEMENT, pas seulement comblé un trou. Le premier
test attendait qu'une phase en export donne une marge SUPÉRIEURE au plafond — c'est ce que le
design affirmait. Il a échoué : `calculateAllowanceAmpere()` part du plafond et ne fait que
des `qMin`, donc la marge y est ÉCRÊTÉE. Physiquement juste, mais la phrase était fausse : la
conclusion tenait, la raison non. Corrigée dans la spec et dans le code.
* C'est la DEUXIÈME fois qu'ajouter de la diversité au mock trouve un défaut plutôt que de
seulement débloquer un test — après `dryRelay`, qui avait révélé le déréférencement du
contrôleur. La diversité du mock n'est pas un confort de test.
* Note écrite à côté de la garde du compteur : l'app dépend de `connected` pour REFUSER
D'AFFICHER, jamais pour COMPTER — et elle a raison. Si `connected` ment, l'écran dit « hors
ligne » à tort, rien n'est mal compté. Une comptabilité n'a pas droit à cette tolérance :
même donnée non fiable, deux usages, deux verdicts. S'abstenir sur un doute est sûr,
accumuler sur un doute ne l'est pas.
* Suite : simulation 64/64, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 03 Sep 2026 17:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm51) trixie; urgency=medium
* URGENT — CONTRADICTION SUPPRIMÉE entre `progressMeasurable` et `regime`. Le premier dit ce
que le MATÉRIEL permet, le second ce que le MOTEUR fait ; seul l'EvAdapter remplissait
`sessionMeasurable`, donc sur toute charge non-VE le premier disait oui et le second non.
L'avertissement de saisie de l'app se déclenchant sur `progressMeasurable == false`, il
restait MUET exactement sur les charges achetées à l'aveugle. `sessionWh` est désormais
alimenté depuis le compteur DÉSIGNÉ pour les charges non-VE.
* LM-1012-d — UN ACCUMULATEUR NE PARIE JAMAIS SUR CE QU'UN PLUGIN FAIT DE SES ÉTATS EN TOMBANT.
Avertissement de l'app intégré AVANT l'écriture, et il était décisif : un compteur qui
disparaît peut publier 0 au lieu de se taire. Mesuré sur la V2C — `currentPower` 4 396 → 0 et
les booléens à false, mais `sessionEnergy` resté à sa dernière valeur. La politique dépend du
plugin, ce qui la rend inutilisable comme garde.
* ET IL N'EXISTE AUCUN SIGNAL DE JOIGNABILITÉ FIABLE — vérifié : `setupComplete` parle du setup
et non du lien, `connected` est maintenu par le plugin (V2C après trois échecs, un compteur
générique peut ne jamais basculer). La garde ne pouvait donc pas reposer là-dessus.
* LA GARDE PORTE SUR LA SÉMANTIQUE DU COMPTEUR, seule chose que le moteur sache sans parier :
un recul sur compteur de SESSION est une session neuve (recapture) ; sur compteur de VIE,
c'est une anomalie (GEL, jamais de recapture). Sans elle : base reprise à 0 pendant
l'absence, retour du compteur avec 10 kWh réels, `deliveredWh` saute à 10 000 en un cycle et
l'obligation se déclare TENUE sans que rien n'ait été livré. R6 ne l'attrape pas — elle
n'interdit ECO_FLOOR_MET que sous `unmeasurable`, et ce chemin-là est MESURÉ. Contre-épreuve :
garde retirée, le régime reste « measured » pendant la panne.
* `tools/audit-controles.py` — écrit en tête qu'il NE TRANCHE PAS et ne doit jamais trancher :
au balayage, neuf des dix identités relevées contrôlaient réellement. Un outil qui aurait
rendu un verdict aurait supprimé neuf contrôles utiles.
* AGENTS.md — la diversité du mock n'est pas un confort de test, c'est une BORNE SUPÉRIEURE sur
ce que la suite peut prouver, et elle se fixe en écrivant les classes, pas les tests.
Troisième occurrence documentée.
* Quatrième occurrence du piège d'insertion doxygen, rattrapée par le même contrôle — comme
annoncé.
* Suite : simulation 63/63, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 03 Sep 2026 15:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm50) trixie; urgency=medium
* BALAYAGE — « qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » passée à toute la
suite. Ce n'est pas une liste de tests à supprimer, c'est la liste de ce qu'on croyait
vérifié et qui ne l'était pas (docs/BALAYAGE_CONTROLES_20260903.md).
* ASSERTIONS CONDITIONNELLES DANS UNE BOUCLE : 0. La seule qui existait a été corrigée le
2026-09-01 ; l'outil le confirme et le surveillera.
* IDENTITÉS AUTO-RÉFÉRENTIELLES : 10 relevées, UNE seule vaine. Le motif signale, la lecture
tranche. `authorisedW − committedW == remainingW` relisait une affectation d'UNE LIGNE :
retirée, avec sa raison écrite à sa place — et la distinction d'avec l'identité du BUDGET,
qui croise `remainingSurplusW` filé le long de la cascade avec `allocatedW` accumulé en TROIS
sites, et qui mérite donc d'être assertée. Supprimer sans dire pourquoi aurait invité
quelqu'un à la remettre.
* LA MOITIÉ QUI COMPTE — l'index annonçait 47 règles « spécifiées seules ». Il mesurait la
CITATION, pas la couverture : DOUZE étaient couvertes par un test qui ne les nommait pas.
Citées par un test : 22 → 40. Spécifiées seules : 47 → 31. Les liens sont désormais
vérifiables par la machine au lieu de reposer sur la mémoire de qui a écrit quoi.
* Sur les 31 restantes, 23 appartiennent à des familles NON IMPLANTÉES (sonde thermique,
transport §14, optimiseur externe) : écrire leur test poserait un invariant sur du code que
rien n'émet. Six sont du cadrage mal classé. Et UNE SEULE manque vraiment — LM-1210-a, la
pire phase faute de masque, qu'aucun test n'exerce PARCE QUE le mock ne sait pas produire une
triphasée déséquilibrée. Même angle mort que l'inventaire, nommé deux fois désormais.
* `tools/audit-controles.py` pérennise les deux motifs. Il ne remplace pas la question, il la
pose là où elle se pose souvent.
* Le résultat le plus utile n'est pas le défaut trouvé, c'est le CHIFFRE CORRIGÉ : on croyait
47 règles non couvertes, il y en avait 31, dont une seule qui manque vraiment.
* Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 03 Sep 2026 10:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm49) trixie; urgency=medium
* CORRECTIF — sur `measured → unmeasurable → measured` DANS LA MÊME PÉRIODE, `deliveredWh`
repartait de zéro. La base était retirée puis reprise à la valeur courante de `sessionWh`,
donc l'énergie livrée pendant les tronçons mesurés antérieurs DISPARAISSAIT. Deux
conséquences, et la seconde coûte : l'écran sous-estime, et la passe éco RACHÈTE ce qui a
déjà été livré.
* PAS THÉORIQUE : c'est le motif d'une V2C dont le lien Modbus bat — chaque perte et retour
fait basculer le régime deux fois, et chaque aller-retour effaçait ce qui précède.
Contre-épreuve : tronçon jeté, l'avancement retombe de 1500 Wh à 0.
* On accumule désormais par PÉRIODE, pas par tronçon. Et la clôture se fait sur la DERNIÈRE
valeur lue tant que la mesure existait, jamais sur celle du cycle où le régime tombe : à cet
instant le Thing ne publie plus, et s'en servir fabriquerait une énergie.
* TRANCHÉ — le cumul de période repart de zéro à la bascule de période, et les trous avec lui.
* TRANCHÉ — `measuredSince` garde le PREMIER instant mesuré, et l'intermittence se dit à part
(`measuredGaps`, publié seulement s'il y en a). Redater à chaque retour effacerait l'histoire
au lieu de la raconter ; ne rien dire ferait croire à une continuité que le battement dément.
L'écran peut écrire « mesuré PAR INTERMITTENCE depuis 09:14 », qui est la phrase vraie.
* LM-1014-c — un cumul se teste aussi de part et d'autre d'un CHANGEMENT DE RÉGIME, pas
seulement de période. LM-1014-b disait quoi faire à la bascule de période et ne disait rien
de la bascule de régime à l'intérieur d'une période : c'est là que le défaut vivait.
* Le test a échoué deux fois avant d'être juste, et la seconde était le test : mon horodatage
« après échéance » visait la MÊME échéance que le premier, donc rien ne basculait.
* Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 02 Sep 2026 11:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm48) trixie; urgency=medium
* MOCK — classe `dryRelay` : un relais SEC, qui commute et ne mesure pas. Elle ouvre le cas que
le banc ne savait pas produire : une charge SAINE et NON MESURÉE. Jusqu'ici
`measurement.source: none` n'était atteignable qu'avec un Thing manquant, ce qui traîne
`available: false` et mélange deux affirmations — « rien ne mesure » et « c'est en panne ».
Un relais sec est banal sur le terrain, et parfaitement sain.
* `testHealthyButUnmeasuredDiffersFromFaulty` vérifie ce que RIEN ne pouvait vérifier avant :
deux charges, même `source: none`, `available` opposés, et un `faultCode` sur la seule qui
est en défaut. Ce test n'aurait pas pu s'écrire hier.
* ET L'AJOUT A RÉVÉLÉ QUE LE MOCK AVAIT SA PROPRE PRÉMISSE FAUSSE : `executeAction()`
déréférençait le contrôleur HTTP de CHAQUE Thing sans vérifier, supposant que toute classe en
possède un. Un relais sec n'en a pas besoin. Le plantage fut immédiat et net ; l'hypothèse,
elle, était invisible tant qu'aucune classe ne la contredisait — l'homogénéité du mock la
protégeait, exactement comme elle masquait les cas qu'il ne savait pas produire.
* AGENTS.md — la règle générale sur les identités : une identité dont les DEUX TERMES viennent
de la même source ne vérifie rien. Elle est vraie par construction, et elle rassure là où
rien ne protège. La question n'est donc pas « tient-elle ? » mais « de quoi parle-t-elle
vraiment ? ». Et c'est le MÊME défaut que la contre-épreuve qui ne mord pas, vu de l'autre
côté : dans les deux cas le voyant est vert, dans les deux cas il n'a rien mesuré. Réflexe
commun : demander ce que ce contrôle aurait attrapé s'il avait échoué.
* Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 02 Sep 2026 09:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm47) trixie; urgency=medium
* R6 — LA MESURABILITÉ DE L'AVANCEMENT SE DÉCLARE DANS `GetLoadConfig`, en `r:`, et non dans
la télémétrie. `measurement` n'existe que pour les charges arbitrées ; or une charge qu'on
DÉCLARE est celle qui n'y est pas encore. L'écran serait muet au moment exact où il doit
parler — celui de la saisie, avant qu'une obligation n'achète à l'aveugle tous les jours.
* TROIS CAUSES, TROIS GESTES : `noMeter` (réparable en une manipulation), `meterWithoutEnergy`
(compteur à remplacer), `noSessionEnergy` (rien à faire, c'est le matériel). Même structure
que les trois sources de DRAW_CAP.
* ET `meterThingId` NE PERMET PAS DE LE DÉDUIRE : un compteur peut publier `currentPower` sans
`totalEnergyConsumed` — mesuré sur l'ECS du banc, et reproduit au test. La puissance ne
suffit pas, il faut une énergie. Contre-épreuve : déduction par `meterThingId` seul, le cas
du banc passe pour mesurable.
* INVENTAIRE À FROID DU MOCK (docs/INVENTAIRE_MOCK.md). Trois fois en trois jours qu'un cas
réel s'est révélé inatteignable par la suite ; ce n'est plus un incident, c'est une propriété
du banc d'essai — il est PLUS HOMOGÈNE QUE LA RÉALITÉ. La table des classes dit ce qui est
discriminant et ce qui ne l'est pas, et quatre cas restent structurellement introuvables,
dont `source: none` pour une charge SAINE (toutes les classes pilotables portent une
puissance) et la branche `temperature`, écrite et jamais exécutée.
* La règle qui en sort : avant d'écrire un test qui exerce une ABSENCE, vérifier que le mock
peut la produire — une assertion sur un cas inatteignable ne passe pas, elle NE S'EXÉCUTE
PAS, ce qui est pire, car elle compte comme verte. Symptôme reconnaissable : la
contre-épreuve ne mord pas. Parade : compter les cas examinés et échouer si le compte est nul.
* AGENTS.md — l'asymétrie des latences notée comme une PROPRIÉTÉ et non une chance : le refus
qui coûte de l'argent est celui de la descente, et c'est le plus vite constaté. Ce n'est vrai
que parce que le seuil vient du mécanisme ; une constante unique perdrait la propriété.
* Et la question opératoire appliquée aux trois autres identités du contrat. Résultat inégal,
et c'est le but : Σ counts == targetW est vérifiée hors de ses termes ; l'identité de `draw`
repose sur le fait que le compteur voie toute la maison, ce que RIEN ne vérifie ;
Σ levels[].targetW == estimatedPowerW est INTERNE AU PLAN et ne dit rien du matériel — il a
fallu `command.divergent`, hors de la formule, pour relier les deux.
* Suite : simulation 61/61, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 01 Sep 2026 14:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm46) trixie; urgency=medium
* LE RECRÉDIT EST CONDITIONNÉ À L'OBÉISSANCE CONSTATÉE. Il rendait au budget la conso d'une
charge sur l'hypothèse qu'on pourra la lui reprendre ; contre un refus établi, les watts
prêtés étaient réalloués aux suivantes pendant que la charge continuait de les tirer.
Mesuré : 4 000 W alloués pour 500 W de surplus réel. Suspension faite, B ne reçoit plus que
les 500 W qui existent.
* LE SEUIL VIENT DU MÉCANISME, jamais d'une constante — `ILoadAdapter::commandSettleS()`. Un
écart RÉCENT est une latence : une charge qui vient d'être commandée n'a pas atteint sa
consigne, et suspendre là ferait osciller ce qui va bien. Latences MESURÉES sur la V2C :
montée ~30 s, descente ~7 s. L'asymétrie joue dans le bon sens — le refus qui coûte de
l'argent est celui de la descente, et c'est le plus vite constaté. Défaut prudent à 60 s :
l'oscillation d'une charge saine est un coût certain, le refus n'est qu'un risque.
* ET ÇA SE DIT : motif `RECREDIT_SUSPENDED` avec `refusedSinceS` et `withheldW`. Une
suspension change l'allocation de TOUTES les charges suivantes ; sans le motif, on
chercherait un défaut de cascade là où une charge n'obéit pas.
* AGENTS.md — UNE CINQUIÈME CLASSE, et la seule qu'aucun contrôle ne pouvait attraper : une
PRÉMISSE FAUSSE SOUS UNE ARITHMÉTIQUE EXACTE. Les quatre autres se laissent prendre par un
instrument ; celle-ci n'a aucune trace — `surplus + recrédit − alloué == restant` tient
exactement, que la charge obéisse ou non. La parade : une identité qui tient ne prouve pas
que le modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut jamais
attraper, c'est une hypothèse sur le monde. D'où la question à poser à chaque identité
publiée : sur quoi repose-t-elle qui ne soit pas dans ses propres termes ?
* Troisième occurrence du même piège d'insertion doxygen — un bloc glissé entre un
commentaire existant et sa fonction. L'outil l'attrape à chaque fois, et c'est exactement
ce pour quoi il existe.
* Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve : suspension débranchée, le
recrédit revient à 4 400 W.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 01 Sep 2026 12:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm45) trixie; urgency=medium
* Commande perdue, étape 2 — L'ÉCART COMMANDÉ/MESURÉ EST PUBLIÉ (`command`), et gouverné par
`measurement.source`. Absent sous `none` : il n'y a rien à comparer, et `divergent: false`
affirmerait une concordance qu'on ne peut pas constater.
* L'ASYMÉTRIE VA DANS LES DEUX SENS, et le contrat la dit maintenant en entier. Sous `device`,
une CONCORDANCE ne prouve rien — plusieurs bornes renvoient leur consigne en guise de
puissance — mais une DIVERGENCE dit que la charge n'obéit pas. Sous `meter`, c'est l'inverse :
la concordance est une preuve, la divergence ouvre trois hypothèses.
* Le test le DÉMONTRE au lieu de l'énoncer : la borne du mock dérive sa puissance de sa propre
consigne, donc expectedW == observedW PAR CONSTRUCTION. La divergence a dû être exercée par
un compteur explicite — seul régime où elle a valeur de preuve.
* UN ÉCART N'EST PAS UN DÉFAUT, et le champ ne le qualifie pas. Véhicule qui limite, câble en
16 A, mode manuel : le même nombre qu'une commande perdue. La cause n'est pas dans les watts,
et un écran qui en ferait une alerte accuserait des installations saines.
* `since` est ce qui rend le champ utile : sans lui, une divergence PERMANENTE ne se distingue
pas d'un déphasage d'un cycle. C'est toute la différence entre le normal et le cas relevé.
* `divergent` est publié plutôt que laissé au client : la tolérance est une connaissance du
moteur. La laisser au client ferait réinventer un seuil par écran.
* AGENTS.md — une contre-épreuve qui NE MORD PAS ne prouve pas que le code est bon, elle
prouve qu'on a perturbé au mauvais endroit. Appliqué à moi-même dans le même lot : mon
assertion « sans mesure, pas de `command` » était VACANTE — aucune charge du test n'avait
`source: none`, la boucle ne s'exécutait jamais, et la garde retirée le test passait
toujours. Toutes les classes du mock portent un état de puissance ; il a fallu une charge au
CÂBLAGE MANQUANT pour produire le cas. La contre-épreuve mord désormais.
* Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 01 Sep 2026 10:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm44) trixie; urgency=medium
* CONTRAT — `remainingWh` sous `unmeasurable` vaut LA CIBLE INTACTE : `remainingWh ==
targetWh`. 2 000 Wh restants sur une cible de 2 000 Wh signifie « rien de mesurable », PAS
« rien livré ». Les deux lectures commandent des phrases d'écran opposées, et l'écran ne peut
trancher que si le contrat le dit. Écrit dans INTERFACE.md.
* Rien n'est soustrait parce que soustraire l'intégration interne ferait franchir la frontière
à une ESTIMATION sous l'allure d'un fait — le chemin de `carBatteryLevel`, refusé en
LM-1009 D3. Que `regime` voyage dans les mêmes params ne suffirait pas : un client lisant
`remainingWh` sans regarder `regime` obtiendrait un nombre calculé sans rien pour le lui dire.
* IDENTITÉ ÉPINGLÉE sous `measured` : progress.deliveredWh + params.remainingWh ==
params.targetWh. Troisième contrôle vérifiable, après Σ counts == targetW et
authorisedW − committedW == remainingW. Elle ne tient QUE sous `measured` — sous
`unmeasurable`, c'est `remainingWh == targetWh` qui prend le relais, et c'est le régime qui
dit laquelle appliquer.
* HONNÊTETÉ SUR LA PORTÉE DE CE TEST : la contre-épreuve naïve (soustraire `deliveredWh` sous
unmeasurable) NE MORD PAS, puisque `deliveredWh` y vaut 0. L'assertion est donc un
COROLLAIRE de `delivered == false`, pas une garde indépendante. La garde réelle est R6, et
elle a été vérifiée mordante au bon niveau : publier l'estimation comme un fait fait sortir
ECO_FLOOR_MET sous unmeasurable.
* Commande perdue, étape 1 — MÉMOIRE de la commande dans EvAdapter. Les adaptateurs en watts
la gardaient déjà (l'écrêtage de verrou l'exige) ; la borne, pilotée par le proxy amont,
n'avait jamais eu à se relire. C'est exactement là que le relevé du 2026-08-31 porte : sans
ce souvenir, l'écart commandé/constaté n'est pas CALCULABLE. Publication seule, comportement
constant. La commande ÉCRÊTÉE est retenue, jamais la demande — comparer la demande ferait
voir un écart là où l'adaptateur a fait son travail.
* Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 01 Sep 2026 08:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm43) trixie; urgency=medium
* DÉPLOYÉ SUR .75 le 2026-09-01, version relue sur la box et `nymead` actif. Les quatre motifs
du §10 sont présents dans le binaire posé — vérifié sur machine, pas déduit du script.
* Piège de vérification, noté : `authorisedW`, `perPhaseBound` et `binding` sont des
QStringLiteral, donc stockés en UTF-16 et INVISIBLES à `strings` par défaut. Un premier
relevé les donnait absents. `strings -el` les trouve. Vérifier une chaîne dans un binaire
demande de savoir comment elle y est encodée.
* ECO_FLOOR_MISSED — le motif a désormais son ÉMETTEUR, et c'est pourquoi il est créé
maintenant. Ce matin il n'en avait pas : le créer aurait posé un invariant sur un code que
rien ne produit, un test toujours vert. La bascule de période (LM-1013-c) l'a fait naître —
une obligation abandonnée est un fait que le moteur produit, et un fait produit se dit.
* TROIS CAUSES, TROIS GESTES : `drawCap` envoie regarder l'abonnement, `loadCapacity` la fiche
technique de la charge, `unmeasurable` ce que la borne publie. `unavailable` s'y ajoute
(ECS-410). Sans la cause, l'écran n'affiche qu'un reproche sans recours.
* `missingWh` ABSENT sous `unmeasurable`, jamais 0 : on ne sait pas ce qui a été livré, donc
pas davantage ce qui manquait. Publier un chiffre inventerait la grandeur même dont
l'absence EST le problème. Mesuré : « non tenue sur 2000 Wh — l'avancement n'était pas
mesurable, et le manque n'est pas chiffrable ».
* R5 VÉRIFIÉE contre la maquette plutôt que supposée conforme : `progress { regime, targetWh,
deliveredWh absent si unmeasurable }` correspond exactement.
* UN ÉCART ASSUMÉ, signalé plutôt qu'aligné en silence : la maquette donnait
ECO_FLOOR_GRID { deliveredWh, targetWh, gridW } ; le moteur publie `remainingWh` au lieu de
`deliveredWh`. Raison : `deliveredWh` vit déjà dans `progress`, et le republier ferait deux
sources pour un même fait (LM-1012). Surtout, `remainingWh` n'est PAS dérivable sous
`unmeasurable` — où `deliveredWh` est absent — et c'est justement le cas où l'on achète.
* R8 — LE POINT DE RUPTURE SE LIT SUR `DRAW_CAP`, JAMAIS SUR UN SOLDE À ZÉRO. `remainingW` à 0
arrive aussi quand tout a été alloué normalement : un écran qui le guetterait signalerait une
rupture inexistante et manquerait la vraie. DRAW_CAP n'est émis que lorsqu'un plafond a
réellement refusé, et il porte lequel. Écrit dans INTERFACE.md.
* Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 11:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm42) trixie; urgency=medium
* §10 étape D (2/2) — LA PASSE ÉCO EXISTE. Deux passes sur la MÊME liste, MÊME ordre ; ce qui
change est la BORNE — l'autorisation de soutirage pour l'éco, le reliquat de surplus pour le
confort. L'ordre lexicographique sur (niveau, rang) en découle : le rang classe les CHARGES,
la hiérarchie éco/confort classe les BESOINS et surplombe le rang.
* UNE SEULE COMMANDE PAR CHARGE ET PAR CYCLE (LM-1003). Le niveau éco ne commande pas : il est
fusionné dans l'action de la passe confort. Émettre deux fois ferait commuter un relais en
passe 1 puis le recommuter en passe 2 — du court-cycling fabriqué par l'arbitre lui-même.
* ECO_FLOOR_GRID REND LISIBLE LA TRAJECTOIRE, PAS L'INSTANT. La cadence ne monte pas, elle fait
un mur : 333 W à six heures de l'échéance, le plafond dès seize minutes. Un client qui lit
« 1000 W achetés » ne peut pas deviner ce qu'il paiera dans deux heures. Le motif porte donc
gridW, remainingWh, targetWh, remainingS, regime et atMaximum — combien, pour quelle
obligation, à quelle échéance, sur quoi on achète, et si l'étalement a cessé.
* R6 ARMÉE À DEUX ENDROITS : `computeEcoTarget()` ne conclut jamais « tenue » sous un régime
non mesuré, et la passe refuse d'émettre ECO_FLOOR_MET dans ce cas. Un invariant qui ne tient
qu'à un seul endroit tient mal.
* L'AUTORISATION DE SOUTIRAGE BORNE L'ÉCO : le plancher éco est une raison d'acheter, pas un
droit de faire disjoncter. Contre-épreuve : bornage retiré, 1000 W au lieu de 400.
* Recrédit attribué à la PREMIÈRE passe où la charge apparaît, par champ explicite (LM-1004) :
la discipline ne se teste pas, le champ si. Le recréditer deux fois rendrait au budget de
surplus une puissance que l'éco a financée au réseau.
* MESURÉ AVANT D'ÊTRE ASSERTÉ, encore : deux niveaux, Σ targetW == estimatedPowerW,
counts ventilés entre budget.allocatedW et draw.committedW. Aucun scénario existant ne
portait d'obligation, donc aucune référence — les valeurs ont été relevées d'abord.
* INVARIANCE PRÉSERVÉE LÀ OÙ ELLE A ENCORE UN SENS : aucune charge existante ne déclare
d'obligation, donc la passe éco ne produit rien pour elles et charging reste IDENTIQUE à la
référence ligne à ligne. C'est le garde-fou qui reste utile dans une étape qui, par ailleurs,
change le comportement.
* Suite : simulation 57/57, loadmodel 22/22, charging 48/48 identique, spotmarket 32/32.
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 10:05:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm41) trixie; urgency=medium
* §10 étape D (1/2) — L'ÉTALEMENT UNIFORME, mesuré avant d'être asserté. `computeEcoTarget()`
convertit une obligation en ÉNERGIE en une cible en WATTS : cadence = reste / temps restant,
recalculée à chaque cycle. « Au plus tard » serait optimal mais suppose une prévision, qui
n'existe pas dans ce modèle (LM-1013-b).
* MESURÉ D'ABORD, ET C'ÉTAIT NÉCESSAIRE. Aucun scénario existant ne portait d'obligation éco —
`LoadContext::needs` n'était rempli par aucun adaptateur — donc aucune référence. Les valeurs
ont été relevées par sonde avant toute assertion, et c'est le relevé qui a révélé un défaut
que la lecture du code ne montrait pas (voir ci-dessous).
* LE DURCISSEMENT, relevé point par point : 2 kWh restants font 333 W à six heures de
l'échéance, 2 kW à une heure, et le plafond dès SEIZE minutes. La fenêtre où l'achat devient
massif n'arrive pas progressivement, elle arrive d'un coup — c'est pourquoi le motif devra
publier la cadence ET le temps restant.
* POINT DE NON-RETOUR nommé : sous un seuil, on cesse d'étaler et on prend le maximum. Un seuil
se discute, une asymptote se subit.
* LM-1013-c — LA BASCULE DE PÉRIODE, trouvée en mesurant. À l'instant de l'échéance
quotidienne, `resolveDeadline()` vise la prochaine occurrence : une nouvelle obligation
s'ouvre. Sans reprise de la base d'avancement, LE LENDEMAIN HÉRITE DE L'ÉNERGIE DE LA VEILLE
et se croit tenu sans avoir rien reçu. Contre-épreuve : 1800 Wh portés au crédit du jour
suivant. Limite assumée : l'obligation non tenue est abandonnée sans motif dédié — le journal
le dit, l'écran non, faute d'un ECO_FLOOR_MISSED que rien n'émet.
* R6 armée dans le calcul : sous un régime `unmeasurable`, on ne conclut JAMAIS « tenue ». On
vise l'obligation entière — le choix qui sert plutôt que celui qui suppose acquis.
* L'heure quotidienne vise la PROCHAINE occurrence : lue à 23:00, une limite de 07:00 vise le
lendemain. La lire comme dépassée ferait franchir le point de non-retour toutes les nuits.
* Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 09:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm40) trixie; urgency=medium
* §10 étape C — L'AVANCEMENT BRANCHÉ EN LECTURE SEULE, et le RÉGIME publié (R5).
`progress.regime` ∈ measured | estimated | unmeasurable, porté par le NIVEAU. Sous
`unmeasurable`, `deliveredWh` est ABSENT et jamais 0 : une dégradation ne doit pas se lire
comme un avancement nul (LM-1009 C1).
* LE PIÈGE, mesuré et évité (LM-1012-b). `sessionEnergy` est l'ACCUMULATEUR du plugin V2C,
pas un compteur remis à zéro pour nous : l'écart avec `chargeEnergy` est constant au
dix-millième (7,2044 kWh sur deux échantillons à deux heures d'écart). Le lire directement
ferait croire toute obligation tenue de 7,2 kWh dès le premier cycle. L'avancement est donc
une DIFFÉRENCE depuis une base — contre-épreuve : lecture directe, le test annonce 7204.
* Base prise à l'ouverture de l'OBLIGATION, jamais au démarrage de nymead : sinon une recharge
de nuit relue au matin repart de zéro. RECAPTURÉE sur un recul franc de l'accumulateur
(nouvelle session), pas sur un recul infime, qui est du bruit de lecture.
* CE QUE LE MOTEUR NE REFAIT PAS : la détection de remise à zéro de la BORNE vit dans le
plugin, qui interroge à 30 s ; le moteur travaille à la minute et raterait des cycles
entiers. Regarder comment le plugin l'avait écrite a évité une seconde accumulation, moins
fine, en concurrence de la première.
* Trois maillons manquants raccordés au passage, tous déclarés et alimentés par personne :
`EvCharger` n'exposait pas `sessionEnergy` ; `LoadContextTelemetry::sessionWh` n'était rempli
nulle part ; et `toLoadContext()` ABANDONNAIT les besoins déclarés, si bien que
`LoadContext::needs` n'était rempli par aucun adaptateur — l'obligation éco n'existait qu'en
configuration.
* Mock : `sessionEnergy` ajouté à `chargerPhaseSwitching` UNIQUEMENT. `charger` reste sans, ce
qui donne le cas `unmeasurable` nativement — la suite porte les deux régimes, comme le banc.
* Le régime est publié ALORS QU'AUCUNE DÉCISION NE S'Y APPUIE, délibérément : cela sépare
publier le régime de décider avec, comme `measurement` l'a été de l'arbitrage, et rend R6
testable le jour où le motif naîtra plutôt qu'un invariant sur du code que rien n'émet.
* Rapport amont rédigé pour le SIGSEGV de `Logger::log()` (docs/upstream/) : « hors de ce
lot » ne veut pas dire sans conséquence — un plantage du moteur de journalisation, c'est
nymead qui tombe chez un client et le HEMS qui cesse de piloter.
* Suite : simulation 56/56, charging 48/48 identique à la référence, loadmodel 21/21,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 08:15:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm39) trixie; urgency=medium
* §10 étape B — `draw` publie l'AUTORISATION, et elle se lit au lieu d'être forgée.
`authorisedW`, `remainingW`, `binding` et `perPhaseBound` étaient absents parce qu'aucun
plafond de soutirage n'existait ; le lot délestage en a créé un. Le refus d'origine visait
la FORGE, pas la lecture — et son motif est tombé sans que sa formulation ne bouge.
* `committedW` publié TOUJOURS, les quatre autres SEULEMENT si un plafond est en vigueur.
Le zéro est une valeur quand la grandeur existe, l'absence en est une quand elle n'existe
pas — un `authorisedW: 0` avec un `binding` par défaut afficherait un plafond inexistant
avec sa source.
* `authorisedW` est SIGNÉ : négatif, la maison est déjà au-delà du plafond, et l'ampleur du
dépassement se lit. L'écrêter confondrait « pile à la limite » et « on dépasse de 1200 W ».
L'identité `authorisedW − committedW == remainingW` tient toujours ; `remainingW` peut être
négatif, et c'est un FAIT — les bornes VE engagent du soutirage borné par LEUR phase.
* ÉCRIT EN TOUTES LETTRES DANS LE CONTRAT, pas en note : sous la réserve batterie,
`budget.remainingW == 0` et `draw.remainingW != 0` dans la MÊME trame. La réserve annule le
surplus, jamais l'autorisation de soutirage — celle-ci est une contrainte physique du
branchement (LM-1203-b vue depuis la frontière). Un client aurait raison de signaler
l'incohérence si personne ne la lui avait dite.
* LE TEST D'ABSENCE EST MORT PROPREMENT, et son successeur dit à quelle condition il devient
faux — application directe du troisième mode d'érosion. L'ancien épinglait « pas de
authorisedW » et a survécu à son motif ; le nouveau épingle la RÈGLE (présent si le plafond
existe, absent sinon) et nomme les deux évolutions qui le périmeraient.
* Suite : simulation 55/55, loadmodel 21/21, spotmarket 32/32, charging 48/48 identique à la
référence ligne à ligne, doxygen 0 avertissement. Contre-épreuve : réserve annulant aussi
l'autorisation → 0 au lieu de 3450.
* NON EXPLIQUÉ, ET SIGNALÉ COMME TEL : un plantage unique de `charging` (SIGSEGV) pendant un
lot de fond, non reproductible en trois séquences. Le vidage le situe dans
`Logger::log()` de libnymea.so.1, atteint par un événement posté depuis libnymea-core —
AUCUNE trame ETM dans la pile. Hors de ce lot, mais consigné plutôt que relancé jusqu'à
disparaître.
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 07:20:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm38) trixie; urgency=medium
* §10 étapes A1 et A2 — décider et émettre se séparent, à comportement CONSTANT. La chaîne de
sélection du motif devient une fonction pure (`choisirMotif`), puis `decideSetpoint()` rend
une CIBLE dont `emitSetpointAction()` forme la commande. Tant que les deux étaient le même
geste, une seconde passe était littéralement inexprimable.
* La contrainte du lot est tenue et vérifiée sur le fichier produit : vouluW capturé AVANT
l'écrêtage de verrou (ECS-309), écrêtage AVANT le décrément du budget (ECS-306), les deux
dans la phase de décision. Un déplacement d'un cran publierait des motifs faux sans qu'aucun
nombre ne bouge. Remaniement fait par ALIAS DE RÉFÉRENCE, pour que les vingt lignes
d'affectation ne soient pas retouchées : ce qui n'est pas réécrit ne peut pas dériver.
* [reproductibilité] UN VERDICT DE TEST NE DOIT PAS DÉPENDRE DE L'HEURE. Six appels à
`update(QDateTime::currentDateTime())` manquaient la garde ENERGY_SIMULATION que leurs
voisins ont : setPhasePowerLimit, setAcquisitionTolerance, et trois dans onActionExecuted.
Un cycle d'arbitrage complet partait donc à l'horloge MURALE au milieu d'une suite qui
injecte un temps simulé ; l'armement paresseux (ECS-412) figeait m_lastSwitch sur l'heure
qu'il était, et un scénario daté en dur se déroulait « avant » son propre armement.
* PREUVE DU CORRECTIF : la garde rendue, testL4ShedsAPacAndSaysItForcedTheLock repasse avec
une DATE FIXE, trois fois sur trois — ce qu'il ne pouvait pas faire. L'ancrage sur l'horloge
réelle était un contournement et il est retiré.
* `tools/check-simulation-clock.py`, câblé en 4/4 de ci-quality.sh, et VÉRIFIÉ MORDANT.
Pourquoi une vérification statique et pas un test : la suite ne peut pas dire d'elle-même
que son verdict dépend de l'heure, puisque c'est le verdict qui ment. Le défaut n'a été
trouvé que parce qu'il échouait CINQ FOIS SUR CINQ — une fenêtre plus étroite ne basculerait
qu'une fois sur vingt, passerait pour une fragilité de temporisation, et serait relancée
jusqu'à ce qu'elle passe. La vérification a d'ailleurs trouvé trois des six déclencheurs
dès son premier passage.
* Suite complète : simulation 54/54, charging 48/48 identique à la référence ligne à ligne,
loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 01:10:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm37) trixie; urgency=medium
* Délestage §4 — LA PROTECTION DE SURCHARGE NE SE LIMITE PLUS AUX BORNES. Une PAC ou un
routeur pouvaient tenir un dépassement sans que rien ne les redescende ; L4 déleste
désormais les charges du waterfall, de la DERNIÈRE SERVIE à la première — le même ordre
total que la cascade, lu à l'envers (LM-1211).
* DÉLESTER N'EST PAS TOUJOURS RETIRER DES WATTS. Une PAC ne se module pas : on la fait
redescendre d'ÉTAT, vers 2 et jamais 1 (ECS-413). C'est exactement le cas que le bornage de
la cascade ne pouvait pas traiter — il empêche d'allouer, il ne retire rien à personne.
* LE FRANCHISSEMENT DE VERROU SE CONSTATE, IL NE SE PRÉSUME PAS (LM-1211-b). Le délestage
tente d'abord SANS forcer et ne marque `minOnBypassed` que si le verrou a réellement refusé.
Le déduire d'un verrou simplement ACTIF serait faux : un verrou peut être armé et permettre
malgré tout le geste demandé. Le marqueur annoncerait un court-cycle qui n'a pas eu lieu, et
le premier compresseur réellement maltraité passerait inaperçu parmi les fausses alertes.
* Ce constat n'était pas possible avant ECS-415 : tant qu'un refus renvoyait l'action
DEMANDÉE, il se lisait comme un succès. Le correctif du contrat était la précondition.
* ON NE FORCE QUE POUR UN PLAFOND SUBI (LM-1211-c). Breaker et GridOperator autorisent le
franchissement, EcoSelfImposed jamais — on renonce au plancher éco plutôt qu'au matériel.
Comportement OBSERVÉ et non postulé : forçage interdit, la PAC de test reste en état 4.
* UN SEUL COMMANDEUR PAR ORGANE (LM-1211-a). Les charges délestées sont revendiquées pour le
cycle et le dispatch les saute ; sans cela la consigne du plan rallumerait ce que la
sécurité vient de couper. Même mécanisme que m_waterfallEvIds pour les bornes.
* FENÊTRE D'EXÉCUTION ÉTROITE, et les deux autres placements sont consignés dans
DESIGN_DELESTAGE §6bis parce qu'ils se représenteront : APRÈS le dispatch on lit un état
périmé (aucune boucle d'événements dans un même update()), et AVANT syncAdapters()
l'écriture part avec l'adaptateur reconstruit (ECS-412). La bonne fenêtre est entre les deux.
* Non couvert, et le rester tant qu'aucun émetteur n'existe : GridOperator et EcoSelfImposed
n'ont pas de source. La branche qui les distingue est écrite et son effet est démontré par
contre-épreuve, mais aucun test de bout en bout ne peut les exercer — en écrire un poserait
un invariant sur un code que rien n'émet. Ils viendront avec le §14a.
* Suite complète : simulation 54/54, charging 48/48 IDENTIQUE à la référence ligne à ligne
(les bornes restent au chemin amont), loadmodel 21/21, spotmarket 32/32, doxygen 0
avertissement. Deux contre-épreuves distinctes : délestage débranché, la PAC reste à 4 ;
forçage interdit, elle reste à 4 aussi — et c'est le comportement voulu d'un plafond
auto-imposé.
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 00:20:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm36) trixie; urgency=medium
* ECS-415 — UN REFUS DOIT SE DISTINGUER D'UNE APPLICATION, par la valeur de retour.
`SgReadyAdapter::applyAction()` renvoyait, quand le verrou minStateHold refusait la
transition, l'action DEMANDÉE telle quelle : un appelant qui relisait `state` y retrouvait
l'état qu'il venait de demander et concluait au succès, alors que rien n'avait été écrit et
que les contacts n'avaient pas bougé.
* CE N'EST PAS UN DÉFAUT DU DÉLESTAGE, C'EST UN DÉFAUT DE CONTRAT. N'importe quelle descente
d'état pouvait échouer en silence, et `available` restait vrai — la charge avait l'air saine
pendant que la PAC tirait 3 kW. Même famille qu'ECS-111 : une issue indiscernable de son
contraire, transposée cette fois au RETOUR d'une commande au lieu de la télémétrie.
* Toute sortie de `applyAction()` décrit désormais l'état RÉELLEMENT TENU — `state` et
`estimatedPowerW`, ou `powerW` et `estimatedPowerW` pour les mécanismes en watts. Six
sorties ramenées à une forme unique côté SG-Ready ; l'idempotence comprise, parce qu'un
appelant qui demande « état 4, 0 W » sur une PAC qui en tire 3000 doit se voir rendre 3000
et non son propre zéro. Même correction sur la branche « motif vide » du RelayRouter et de
l'EtmVariableLoadAdapter ; leurs branches de défaut étaient déjà justes.
* DÉFAUT LATENT, DEVENU GRAVE À L'USAGE. Aucun appelant ne lisait ce retour — le dispatch
l'ignore — donc rien ne le révélait. Le premier à s'y fier fut le délestage L4 en cours
d'écriture, qui a publié 3 kW rendus par une PAC restée en état 4. Un contrat qu'aucun
appelant n'exerce n'est pas un contrat tenu : c'est un contrat non testé.
* `m_currentState` n'était PAS en cause : il est écrit synchroniquement, sans attendre aucun
acquittement, et le repli sur échec d'écriture passe par settleTransition(). L'hypothèse
d'une écriture conditionnée à un retour tardif était fausse, et c'est la sonde qui l'a dit.
* ECS-411-b ne s'applique pas ici : la relecture des contacts (construction, ClearLoadFault)
répond à une divergence entre état interne et matériel. Il n'y en avait aucune — l'état
interne était juste, c'est le RETOUR qui mentait.
* Trois pièges de l'étape 4 consignés dans DESIGN_DELESTAGE §6bis, y compris les deux
placements écartés : après le dispatch on lit un état périmé (aucune boucle d'événements
dans un même update()), et avant syncAdapters() l'écriture part avec l'adaptateur reconstruit
(ECS-412). L'étape 4 elle-même reste NON LIVRÉE.
* Suite complète : simulation 53/53, charging 48/48, loadmodel 21/21, spotmarket 32/32,
doxygen 0 avertissement. Contre-épreuve : sur l'ancien retour, `refuse.state` vaut 2 —
l'état demandé — au lieu de 4.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 30 Aug 2026 23:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm35) trixie; urgency=medium
* Délestage §3 — LA CASCADE TIENT DANS DEUX RESSOURCES. Le surplus dit ce qu'on peut dépenser
sans acheter ; l'autorisation de soutirage dit ce que le branchement supporte. Une charge
doit tenir dans les deux. Jusqu'ici seules les bornes VE étaient bornées par la seconde : une
charge de waterfall pouvait créer un dépassement que seule la protection L4 rattrapait, après
coup. C'est le changement de comportement du lot, et il ne touche QUE les charges non-EV.
* Motif `DRAW_CAP` (R8) — porte `marginW` (SIGNÉ), `overshootW`, `source` (code opaque :
connection | gridOperator | selfImposed) et `phase` (A/B/C, ABSENT si le plafond est un
total — son absence dit qu'aucune phase n'est coupable, elle ne vaut pas « phase A »).
Chacune commande un geste différent, et aucune ne se déduit des autres.
* PRÉCÉDENCE LM-1011, déjà gravée, appliquée telle quelle : `DRAW_CAP` uniquement quand
l'autorisation résiduelle est NULLE. S'il en reste, fût-ce moins que le plus petit palier,
c'est `BELOW_MIN_POWER` — « il y en a, mais pas au bon format ». Le soutirage hérite du
couple que le surplus avait déjà.
* Les bornes VE ne sont PAS écrêtées par la marge de la maison, et ce n'est pas un oubli :
leur plafond de cycle connaît LES PHASES QU'ELLES OCCUPENT. Leur appliquer en plus la pire
phase les priverait d'une phase saine parce qu'une autre sature. Entre deux bornes justes on
garde la mieux informée, pas la plus sévère. Elles décrémentent en revanche la marge, comme
toutes les autres : ne décompter que les charges qu'on borne offrirait aux suivantes une
marge déjà dépensée.
* LIMITE DE MODÈLE, ASSUMÉE ET DOCUMENTÉE (LM-1210-a) : `LoadDeclared::phases` est un NOMBRE,
pas un masque — le moteur ignore sur quelle phase une charge est câblée. La marge retenue est
donc celle de la PIRE phase. Le pessimisme est borné en pratique (une phase qui exporte a une
marge plus grande que le plafond et ne borne personne) et coûte du rendement, jamais de la
sécurité. Lever la limite demande un masque déclaré : config, interface, migration — hors lot.
* Suite complète : simulation 52/52, charging 48/48 IDENTIQUE à la référence ligne à ligne
(les bornes ne bougent pas, c'est l'invariant du lot), loadmodel 21/21, spotmarket 32/32,
doxygen 0 avertissement. Contre-épreuve : bornage débranché, la charge est servie 1000 W
alors que l'autorisation est dépassée de 460 W.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 30 Aug 2026 21:15:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm34) trixie; urgency=medium
* [upstream-fix] rootmeter — `calculateAllowanceAmpere()` lisait un AUTRE état que celui
qu'elle testait. Les branches des phases B et C testaient `currentPhaseB`/`currentPhaseC`
(ampères) puis lisaient `currentPowerPhaseB`/`currentPowerPhaseC` divisés par 230 (watts),
là où la phase A lit bien l'état qu'elle teste.
* CE CORRECTIF CHANGE LE COMPORTEMENT, ET DANS LE BON SENS. Des bornes seront écrêtées qui ne
l'étaient pas : ce n'est pas une régression, c'est la protection de surcharge qui REDEVIENT
EFFECTIVE sur B et C. Un compteur publiant les courants par phase sans les puissances par
phase entrait dans la première branche, y lisait 0, et se voyait accorder une place libre
égale au plafond ENTIER — la protection n'écrêtait rien et ne signalait rien. Sur une
triphasée déséquilibrée, le disjoncteur restait le seul rempart, c'est-à-dire L0 employé
comme régulateur au lieu de dernier recours. Qui lira cette ligne comme une régression lira
le symptôme, pas la cause.
* AUCUN TEST DE CE DÉPÔT NE POUVAIT LE TROUVER. Le mock pose `currentPhaseB` =
`currentPowerPhaseB / 230` : les deux états y sont cohérents par construction, donc lire
l'un ou l'autre y donne le même nombre. Il a fallu désaccorder les états à la main, ce
qu'aucun chemin normal du mock ne permet (`testPhaseAllowanceReadsTheStateItTests`, qui
échoue sans le correctif en annonçant 25 A de place libre sur une phase qui en tire 20).
L'angle mort est nommé comme une CLASSE dans SAFETY.md : l'installation de référence
n'exerce pas le cas, et une suite verte ne dit alors rien sur lui.
* Un défaut voisin de la même fonction est consigné SANS être corrigé : `voltage` est déclarée
hors des trois blocs et mutée par celui de la phase A, donc la tension de A peut servir à
calculer B. Plus contraint, aucun relevé ne le montre, et il aura son propre correctif isolé.
* Suite complète : simulation 51/51, charging 48/48 (identique à la référence, ligne à ligne),
loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 30 Aug 2026 19:40:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm33) trixie; urgency=medium
* R7 — `decision.level` : le motif hérité dit de quel NIVEAU il parle. `decision` reste au
niveau de la charge, mais il reflète le motif d'UN niveau ; sans le dire, chaque client
réinventerait sa règle de fusion (« le motif du dernier niveau servi ? du premier refusé ? »)
et deux écrans afficheraient deux motifs pour le même cycle. Il n'y a qu'un niveau
aujourd'hui et la question ne se pose pas — c'est précisément pourquoi le champ arrive
maintenant : quand elle se posera, les clients le liront déjà.
* PRÉSENT EXACTEMENT QUAND levels[] L'EST. En mode dégradé il n'y a pas de passe, donc pas de
niveau dont le motif parlerait : ni levels[], ni decision.level.
* R1 — l'ABSENCE et le ZÉRO disent deux choses différentes, et les deux moitiés sont épinglées.
Niveau absent = passe non parcourue ; niveau à targetW 0 = passe parcourue, rien à prendre.
Un zéro forgé détruirait la première information, une absence la seconde. Aujourd'hui la
passe unique est toujours parcourue dès qu'une charge est arbitrée : c'est le mode dégradé
qui porte le cas « aucune passe ».
* testLevelAbsenceAndZeroDifferInMeaning, vérifié échouant DANS LES DEUX SENS — un niveau à
zéro omis, et un decision.level publié sans levels[].
* LM-1014 — UN CLIENT TESTE LA PRÉSENCE D'UN CHAMP, JAMAIS UNE VERSION. Règle posée par
l'agent app en déplaçant son exception EV_GRID_START plutôt qu'en la retirant : elle survit
là où la charge utile n'a pas de counts. Ce qui rend une capacité détectable est la charge
utile, pas un numéro — une box se rétrograde, un paquet se reconstruit, une branche se
déploie hors séquence. Portée en tête d'INTERFACE.md : les versions y datent les changements,
elles ne sont pas des conditions à tester.
* R5, R6 et R8 NE SONT PAS REPOUSSÉES, elles sont sans objet : vérifié que ECO_FLOOR_MET,
ECO_FLOOR_GRID et DRAW_CAP n'existent pas dans le catalogue de motifs. Écrire aujourd'hui la
garde de R6 poserait un invariant sur un code que rien n'émet — un test toujours vert, donc
un test qui ne protège rien.
* Suite complète : simulation 35/35, charging 17/17, loadmodel 20/20, spotmarket 7/7. amd64
0 erreur / 0 avertissement, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 30 Aug 2026 17:10:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm32) trixie; urgency=medium
* R3 — `counts` : chaque watt publié tombe dans exactement un compteur, et le mapping est
PUBLIÉ. Chaque niveau porte { destination → watts }, avec Σ counts == targetW toujours.
Clés opaques (budget.allocatedW, draw.committedW), jamais recyclées pour un sens nouveau —
recycler ferait ranger des watts dans le mauvais compteur sans qu'aucune erreur ne le
signale, même règle que les numéros de règle retirés.
* Publié TOUJOURS : même à une seule destination, même à zéro. Un `counts` vide satisferait
l'identité en perdant la destination, sur le cas le plus fréquent. Zéro AVEC sa destination
dit « cette grandeur existe et vaut 0 » ; l'absence dirait « elle n'existe pas ».
* funding et counts ne se dérivent PAS l'un de l'autre : funding dit l'ORIGINE des watts,
counts le REGISTRE où ils sont comptés. Sur un EV_GRID_START l'un vaut « grid » quand
l'autre porte deux destinations.
* L'IDENTITÉ DE RÉCONCILIATION NE PASSE PLUS PAR funding : elle somme des compteurs, plus des
origines. EV_GRID_START y entre sans exception — 900 W à l'allocation, 480 W au soutirage.
* LA GARANTIE EST UNE MACHINE, PAS SEPT SITES DE CONSTRUCTION. testCountsSumToTarget relit la
CHARGE UTILE — toutes les charges, trois régimes dont l'import où tout vaut 0 — et vérifie
l'identité, la non-vacuité de counts et l'appartenance des clés au catalogue. Un huitième
site qui oublierait counts échoue ici, pas en relecture de code.
* Et la contre-épreuve a trouvé un trou DANS LE TEST : amputer le partage d'EV_GRID_START le
laissait passer, la borne auto-provisionnée en queue ne voyant jamais de budget. Corrigé —
rang 1, régime EV_GRID_START en premier (une fois la borne en charge, son recrédit porte le
budget au-dessus du plancher et le régime ne peut plus se produire), plus une assertion qui
EXIGE que le régime ait été exercé.
* GetChargingInfos FILTRE enfin sur evChargerId. Le paramètre était déclaré au schéma,
documenté « for all or a single EV charger », et Q_UNUSED : accepté, sans effet, sans
erreur. Des trois issues c'était la pire — un paramètre absent se voit, un paramètre refusé
se voit, un paramètre ignoré ne se voit pas. Identifiant inconnu → liste VIDE, jamais une
erreur : toute borne configurée ayant une entrée, la liste vide est non ambiguë.
* Balayage du namespace : sur 20 méthodes, 9 déclarent des paramètres et GetChargingInfos
était la SEULE à en ignorer un. La famille était petite.
* Suite complète : simulation 34/34, charging 17/17, loadmodel 20/20, spotmarket 7/7. amd64
0 erreur / 0 avertissement, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 30 Aug 2026 14:20:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm31) trixie; urgency=medium
* `draw.committedW` — LE REGISTRE DES WATTS ACHETÉS, préalable à R3. Publié dans la MÊME TRAME
que `budget` : les deux décrivent le même cycle, et les lire à deux instants ferait comparer
deux mondes — l'identité de réconciliation redeviendrait temporelle par un autre chemin.
* SEUL, et délibérément. `authorisedW`, `remainingW`, `binding` et `perPhaseBound` viendront
avec le transport du §14a : ABSENTS, jamais à 0. Un `authorisedW: 0` assorti d'un `binding`
par défaut ferait afficher un plafond de soutirage qui n'existe pas, avec sa source — un
chiffre crédible et faux, c'est-à-dire la faute même que cet objet doit rendre impossible.
* budget.evReservedW N'A PLUS QU'UNE NATURE. Il recevait la part réseau d'EV_GRID_START, une
vraie tranche d'allocation, EN PLUS de la correction A (commandé non encore mesuré). Cette
part est passée dans draw.committedW. Sans ce déménagement, aucune définition du champ
n'aurait été vraie pour ses deux termes.
* Et sa définition est écrite, parce que l'app l'affichait sous « Réservé au véhicule » : c'est
une CORRECTION du budget de surplus, pas une réservation. Elle tend vers 0 à mesure que la
mesure rattrape la commande, sans qu'aucune décision n'ait changé — un rattrapage de latence.
Le contrat dit désormais : ne l'additionner à rien, il ne se réconcilie jamais.
* EV_GRID_START CESSE D'ÊTRE UNE EXCEPTION. Ses deux moitiés tombent maintenant dans des
compteurs de même nature — 900 W dans budget.allocatedW, 480 W dans draw.committedW — et
budgetW + gridW == allocatedW se lit sans entorse. Le partage demeure, c'est un fait ; ce qui
disparaît est l'exception à connaître.
* Ce que draw compte : ce que le moteur a DÉCIDÉ d'acheter selon son modèle de financement, pas
ce que le compteur verra passer. Pour une action du proxy — échéance, tarif dynamique —
toute l'allocation est comptée comme achetée : l'échéance décide de charger qu'il y ait du
soleil ou non, et aucun partage n'est calculé. Là où le moteur SAIT partager, il partage.
* Publication seule : aucune arithmétique de budget ne change. Vérifié avant l'écriture — la
ligne qui alimentait evReservedW depuis la cascade n'avait aucun retour dans le calcul.
* Suite complète : simulation 33/33, charging 16/16, loadmodel 20/20, spotmarket 7/7. amd64
0 erreur / 0 avertissement, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 30 Aug 2026 11:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm30) trixie; urgency=medium
* R2 — LE FINANCEMENT DESCEND AU NIVEAU. `funding` était par CHARGE ; il vit désormais dans
`levels[]`. Au §10 la même charge sera financée au réseau pour son plancher éco et au surplus
pour son confort DANS LE MÊME CYCLE : un financement par charge y devient indécidable, et
c'est lui qui porte l'identité de réconciliation de l'app.
* Déplacé MAINTENANT bien que le §10 ne soit pas écrit : c'est la seule des huit exigences de
la maquette qui soit un changement de FORME d'un champ existant, donc la seule qui se
renchérisse à chaque écran qui s'y appuie. Les sept autres attendent sans se renchérir.
* UN SEUL NIVEAU aujourd'hui, et c'est « comfort » : la passe unique d'aujourd'hui est celle
qui servira le confort depuis le surplus. La passe éco est la nouveauté du lot, pas la passe
existante rebaptisée. Une charge sans obligation éco n'aura jamais d'entrée « eco » à zéro —
l'absence est l'information (R1).
* `funding` N'EST PAS conservé en résumé sur la charge. Le cas mixte est justement celui qui le
rendrait indécidable : « mixte » serait un troisième code que personne n'a demandé, « grid si
l'un l'est » une convention que chaque client redériverait autrement. Deux endroits pour un
fait divergent. Le motif `decision` reste, lui, parce qu'on saura dire de quel niveau il
parle (R7) — on garde un champ hérité quand on peut en dire quelque chose de VRAI, jamais par
symétrie.
* L'IDENTITÉ DE RÉCONCILIATION est écrite dans INTERFACE.md et non laissée à reconstruire :
somme sur les charges, somme sur les niveaux dont funding == "surplus", de niveau.targetW ==
budget.allocatedW. Elle a changé de forme avec R2 ; un client qui la reconstruirait depuis
l'ancienne divergerait au premier cas mixte, c'est-à-dire au premier cycle du §10.
* L'unique exception est publiée : EV_GRID_START partage une allocation entre deux compteurs,
et sa contribution au surplus est decision.params.budgetW, pas targetW. C'est ce que R3
généralisera ; d'ici là l'exception est dans le contrat, pas dans la tête du lecteur.
* `levels[]` absent = box antérieure, OU mode dégradé — sans plan, il n'y a pas de financement.
* Rupture assumée pour un client antérieur qui lirait `entry.funding` : même arbitrage que
measuredW → measurement, pas d'alias, deux noms pour une chose divergent.
* Suite complète : simulation 33/33, charging 16/16, loadmodel 20/20, spotmarket 7/7. amd64
0 erreur / 0 avertissement, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Sat, 29 Aug 2026 16:40:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm29) trixie; urgency=medium
* ECS-111 DÉFINI ET CORRIGÉ — un relais déclaré qui manque au registre nymea rend la charge
INDISPONIBLE. Le comportement était « non défini » depuis l'écriture de la spec :
findConfiguredThing rendait nullptr, writeRelay loguait et continuait, telemetry() SAUTAIT le
relais. Deux issues, toutes deux fausses — ou bien plus aucun relais actif ne mesurait et
l'on retombait sur le nominal du PALIER ENTIER, part du relais fantôme comprise ; ou bien des
survivants mesuraient et l'on publiait une somme SOUS-ÉVALUÉE.
* Ce n'était pas un défaut d'affichage. telemetry().currentPowerW alimente le recrédit
anti-clignotement, donc le BUDGET : l'arbitre se trompait sur le surplus disponible pendant
qu'available restait vrai. Un mensonge qui décide, pas un mensonge qui s'affiche.
* La règle retenue est celle de la famille : available faux, faultCode THING_MISSING, et le
traitement d'ECS-410 — sortie de l'ARBITRAGE, maintien dans la COMPTABILITÉ, figée à la
puissance crue engagée. La puissance est donc RETIRÉE du budget des charges suivantes au lieu
de leur être recréditée. Aucune somme partielle n'est jamais publiée.
* Écartée : « compter 0 W et servir le reste ». Un relais absent du registre est un Thing
supprimé ou un plugin qui ne le voit plus — on ne sait pas dans quel état est le contact, et
servir le reste reviendrait à commander une charge dont on ne connaît plus la configuration
matérielle.
* Jugé sur TOUS les relais déclarés, jamais sur ceux du palier courant : au palier 0 aucun n'est
fermé, et juger là-dessus ferait basculer la disponibilité au rythme du thermostat. Même règle
que deviceMeasurement(), qui traitait DÉJÀ le cas correctement en rendant source: "none" —
aligner l'ancien sur le neuf, pas inventer une politique.
* THING_MISSING prime sur WRITE_FAILED dans runtimeView() : un relais absent EXPLIQUE les
écritures qui échouent, et annoncer WRITE_FAILED enverrait l'opérateur au bus quand la cause
est dans la configuration. Corollaire écrit : ClearLoadFault lève le défaut d'ÉCRITURE mais ne
fait pas exister un Thing — les deux causes se lisent séparément.
* testEcsPartialFailure a dû être ajusté, et c'est instructif : il se servait d'un relais JAMAIS
CONFIGURÉ comme raccourci pour forcer un échec d'écriture. Le raccourci a désormais un sens
propre. Le test distingue maintenant les deux — le défaut collant se lève, le câblage
manquant non.
* Éprouvé par testVanishedRelayFreezesTheLoad, vérifié échouant sans le correctif, dont la
troisième assertion est celle qui compte : ce que le fantôme laissait aux charges SUIVANTES.
* TROUVÉ PAR LA LECTURE D'UNE RÈGLE, ni par un test ni par le banc. ECS-111 dormait dans une
table de statut supprimée depuis, et est ressortie dans les quatorze règles « spécifiées
seules en territoire livré » que tools/gen-rules.py a listées. Premier défaut réel exhumé par
le chantier de documentation.
* Passe aussi : trois citations de règles ajoutées (LM-400, LM-301-b, LM-600), quatre règles
passées en cadrage, et la classe « cadrage » elle-même — 70 « spécifiée seule » au départ,
33 aujourd'hui. Suite complète : simulation 33/33, charging 16/16, loadmodel 20/20,
spotmarket 7/7. amd64 0 erreur / 0 avertissement, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Sat, 29 Aug 2026 15:05:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm28) trixie; urgency=medium
* LE RATTRAPAGE DE CONFIGURATION EST CYCLIQUE (LM-1209-e). provisionEvChargerConfig() vivait
dans la branche « l'adaptateur vient d'être créé » de syncAdapters(). Or l'adaptateur est
construit depuis le THING : il survit à la suppression de l'entrée de configuration. Une
entrée supprimée depuis l'app n'était donc JAMAIS recréée — mesuré sur .75 le 2026-08-29,
entrée retirée, Thing vivant, rien en 140 s.
* Ce que cela produisait est le trou de 3g-2 rouvert par le côté client : la borne restait
ARBITRÉE mais ABSENTE de GetLoadConfig, à un rang de queue non réglable (evPriority() →
1000). Elle consommait du budget sans que personne puisse la classer, et rien ne le disait.
Seul un redémarrage de nymead réparait.
* La condition devient un ÉTAT et non un ÉVÉNEMENT : « cette borne n'a pas d'entrée » au lieu
de « l'adaptateur vient de naître ». Un rattrapage dont la condition est un événement ne
rattrape rien, puisqu'il ne se rejoue pas. Même distinction qu'entre ECS-411 — relire l'état
réel des relais — et l'hypothèse de départ qu'il a remplacée.
* Une entrée recréée revient en rankOrigin "auto", et c'est juste : personne ne l'a classée,
son rang est de nouveau un défaut. Le rang posé par l'installateur est PERDU avec l'entrée.
Supprimer une entrée n'est donc pas un moyen de retirer une borne de l'arbitrage — elle
revient, déclassée, en queue et avec son badge. enabled: false reste la voie, et conserve le
rang. La documentation promettait l'inverse à l'app : corrigée.
* Le REFUS de provisionnement n'est annoncé QU'UNE FOIS par borne. Le rattrapage étant
cyclique, répéter l'avertissement en produirait un par minute et pour toujours — règle déjà
posée pour les charges sans décision dans buildTelemetry(). Le drapeau tombe au premier
succès : un défaut qui se résout se redit s'il revient.
* LM-1209-d — le rang est un ordre TOTAL, les domaines ne le partitionnent pas. L'insertion en
queue entrelace les domaines ; exiger la contiguïté serait contradictoire avec la raison même
de LM-1209, puisque insérer une charge d'un autre domaine décalerait les suivantes. Grouper à
l'affichage oui, à l'écriture non. Aucun code : c'est une règle de contrat.
* INTERFACE.md porte désormais en tête l'avertissement de sonde : un refus arrive en
status: "success", le verdict étant dans params.energyError.
* Éprouvé par testDeletedEvChargerConfigComesBack — suppression, rattrapage au cycle suivant,
retour en queue et en "auto", et le cas négatif : le rattrapage ne se rejoue pas quand
l'entrée existe, sans quoi il réécrirait la config à chaque cycle et réarmerait les verrous.
Vérifié échouant avec l'ancien comportement. Suite complète : simulation 32/32, charging
16/16, loadmodel 20/20, spotmarket 7/7. amd64 0 erreur / 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Sat, 29 Aug 2026 11:20:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm27) trixie; urgency=medium
* L'AUTO-PROVISIONNEMENT REMPLIT UN TROU, IL NE DÉCIDE PAS (LM-1209). Une borne détectée
entrait EN TÊTE de la liste — rang min − 1, écrasé à 1 — donc à égalité avec la charge que
l'installateur avait posée là exprès. Elle entre désormais EN QUEUE : 1 + max(rangs). Le rang
est un choix de l'installateur ; une détection ne réordonne pas ce qu'un humain a décidé.
* Ne contredit pas le défaut « le VE d'abord » : celui-là décrit une MISE EN SERVICE INITIALE,
où personne n'a encore rien classé — liste vide, max + 1 vaut 1. Un ajout ultérieur est une
autre situation : il existe un classement et il fait autorité.
* Queue de LISTE et non de DOMAINE, et c'est plus fidèle au principe : les domaines ne sont pas
contigus, donc « juste après le dernier ev » exigerait de pousser une charge d'un autre
domaine. Comme max(domaine) ≤ max(liste), la queue de liste est toujours au moins aussi
reculée — elle coïncide quand le domaine occupe la fin, et sinon place la nouvelle charge
derrière tout le monde plutôt que de l'intercaler devant une charge classée.
* UNICITÉ DE RANG GARANTIE, PAS SUPPOSÉE (LM-1209-a). SetLoadConfig refuse en bloc un ensemble
à rangs doublés. Ce que l'égalité coûte n'est PAS le déterminisme — le waterfall trie sur
(rang, identifiant) depuis 3g-2, l'ordre est stable — c'est qu'il devient NON RÉGLABLE : il
se décide sur des identifiants, donc sur personne. Gouvernabilité, pas déterminisme.
* UNE VÉRIFICATION QUI ÉCARTE ET UNE QUI REFUSE NE PEUVENT PAS ÊTRE LA MÊME FONCTION
(LM-1209-a-bis, règle générale). load() applique validateSet() en ÉCARTANT l'entrée fautive
— bon geste pour un conflit d'organe. Y ajouter l'unicité de rang aurait fait perdre des
charges au démarrage sur une configuration héritée : disparues de la configuration ET de
l'arbitrage, sans applySafeState, sur la foi d'une règle resserrée après coup. validateRanks()
est donc séparée et n'est appelée que par setConfigs(). Migration : au chargement, doublon
signalé bruyamment et CONSERVÉ ; la première écriture cliente répare.
* LA CRÉATION SE DIT : o:rankOrigin, énumération fermée "auto" | "user", absente par défaut
(LM-1209-c). Une entrée créée d'office prend une place dans l'ordre de service ; sans marque,
c'est un changement d'arbitrage silencieux, et le journal ne suffit pas — l'app ne le lit pas
et c'est elle qui montre le classement.
* Énumération et non booléen : `rankIsDefault: false` AFFIRMERAIT que le rang a été choisi,
alors que c'est ce qu'on ignore d'une configuration héritée. L'absence doit porter le « on ne
sait pas », et un booléen ne sait pas se taire. Même motif que measurement.source.
* L'EFFACEMENT PORTE SUR LE RANG, JAMAIS SUR L'ÉCRITURE REÇUE. SetLoadConfig remplace en bloc :
réordonner une AUTRE charge renvoie forcément l'entrée de celle-ci, et l'effacer à cette
occasion perdrait une marque encore vraie. L'omission de la clé ne lève rien ; pour lever
sans déplacer, un client envoie "user". Un "auto" fabriqué par un client est refusé, mais
l'aller-retour verbatim reste licite — sans quoi la neutralité Get→Set serait rompue.
* Éprouvé par testAutoProvisionedRankGoesToTheQueue (simulation), vérifié échouant avec
l'insertion en tête, et par testRankUniquenessRefusesWriteButNeverDiscards +
testRankOriginFollowsTheRankNotTheWrite (loadmodel) — dont la moitié qui ne se voit pas :
qu'un chargement CONSERVE ce qu'il aurait pu écarter. Suite complète : simulation 31/31,
charging 16/16, loadmodel 20/20, spotmarket 7/7. amd64 0 erreur / 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Fri, 28 Aug 2026 17:40:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm26) trixie; urgency=medium
* LA MESURE D'UNE CHARGE SE LIT SUR L'APPAREIL PILOTÉ (LM-1105). Il fallait jusqu'ici désigner
une borne comme SON PROPRE compteur dans meterThingId pour qu'elle publie ce qu'elle mesure
déjà. Ce n'est devinable par personne — c'est de la configuration demandée à l'installateur
pour compenser ce que le moteur peut déduire. Quand le Thing piloté porte lui-même l'état de
puissance, le moteur le lit, sans configuration.
* La règle porte sur le THING, pas sur le mécanisme : certains powerswitch portent currentPower
et RelayRouter sait déjà les sommer. Et le test se pose sur la CLASSE, sur TOUS les Things
déclarés — jugé sur les relais actifs, il répondrait « non mesurable » au palier 0, et le bloc
de mesure apparaîtrait et disparaîtrait au rythme du thermostat.
* Le compteur explicite reste PRIORITAIRE, et pas d'abord parce qu'il mesure mieux : un réglage
sans effet est pire que pas de réglage. La priorité porte sur la SOURCE, pas sur la valeur —
compteur déclaré injoignable, on retombe sur l'appareil plutôt que de taire une mesure qu'on a.
* measuredW DISPARAÎT au profit de measurement {source: meter|device|none, o:powerW} (LM-1106).
Le moteur portait déjà trois régimes sous un seul nom et jetait la distinction : evcharger
rend une mesure, relay-router une mesure OU le palier nominal — le booléen `metered` était
calculé puis jeté — et sg-ready une puissance DÉCLARÉE, jamais mesurée. Le régime se PUBLIE,
il ne se déduit pas d'une valeur.
* `none` est un état POSITIF, et powerW y est OMIS : la puissance nominale reste lisible dans
mechanism.*, elle ne se fait pas passer pour une mesure. Un écart commandé↔mesuré fabriqué
depuis un nominal serait identiquement nul à tous les cycles — un « tout concorde » permanent,
corollaire LM-104.
* Deux affinements trouvés en implantant. LM-1105-a : une mesure PARTIELLE n'est pas une mesure
— trois relais dont un seul mesure annonceraient 500 W là où 1 500 W coulent. LM-1105-b : la
règle présuppose que le Thing piloté est TRAVERSÉ par la puissance ; un contact SG-Ready est
un contact de SIGNAL, et sg-ready est donc `none` par CONSTRUCTION, même sur des relais qui
mesurent. C'est le seul mécanisme où meterThingId reste la seule voie.
* ILoadAdapter::deviceMeasurement() est PURE VIRTUELLE, comme runtimeView() : un mécanisme
ajouté qui hériterait d'un « non mesurable » par défaut sortirait de la mesure sans bruit.
* Ne touche PAS ce que l'arbitre lit : LM-1101 tient, LoadTelemetry::currentPowerW est inchangé,
et le recrédit anti-clignotement n'a jamais transité par meterThingId.
* Éprouvé par testMeasurementSourceIsPublished — trois sources, palier 0, PAC en `none`, et les
deux règles de priorité ; la règle « capacité jugée sur la classe » vérifiée échouant quand on
la juge sur les relais actifs. Suite complète : simulation 30/30, charging 16/16, loadmodel
18/18, spotmarket 7/7. amd64 0 erreur / 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Fri, 28 Aug 2026 16:05:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm25) trixie; urgency=medium
* UNE BORNE SANS VOITURE ASSIGNÉE RESTE ARBITRÉE (LM-1208-b). Le cas de l'invité : au banc le
2026-08-28, la V2C tirait 3,9 kW et l'arbitre l'ignorait — evParticipatesInWaterfall()
reprenait du proxy un critère « voiture assignée » qui n'a de sens que là-bas. Le waterfall
n'a besoin ni du SoC, ni de la capacité, ni d'une échéance pour allouer du surplus.
* Trois raisons de le retirer, la troisième décisive : une voiture d'invité ne sera jamais
déclarée et la borne cesserait d'être arbitrée précisément quand elle consomme ; le critère
n'est pas vérifiable, l'assignation étant déclarée à la main ; et il CONTREDIT LM-1206, qui
donne le rang et le plancher à la borne, pas au véhicule. Ce qu'on perd sans voiture est une
FONCTION — échéance et cible d'énergie, restées au proxy — pas l'arbitrage.
* Restent deux exclusions, et elles ne disent pas la même chose : mode manuel (une INTENTION —
l'utilisateur pilote) et aucun véhicule branché (un CONSTAT — rien à consommer).
* SANS VOITURE, LE NOMBRE DE PHASES VIENT DE LA BORNE (LM-1208-c). Retirer le critère ne
suffisait pas, et ce qui manquait ne se voyait pas : le proxy ne PRÉPARE pas une borne sans
voiture, elle entre donc dans le waterfall sans ChargingProcessInfo — et l'accesseur rendait
un objet par DÉFAUT dont les valeurs sont plausibles, une phase et pas de bascule. Une borne
triphasée aurait été annoncée monophasée, budget divisé par trois : plafond 3 680 W au lieu
de 11 040, 16 A commandés au lieu de 9, 11 kW tirés d'une enveloppe qui en comptait 3,7.
* D'où internalHasProcessInfo() : « cette borne a-t-elle été préparée ce cycle » devient une
question POSABLE au lieu d'être déduite d'une valeur crédible. Sans ChargingProcessInfo on
ne bascule pas — basculer suppose de savoir ce que la voiture accepte — et on retient le
phaseCount de la borne. Hypothèse conservatrice au sens de LM-104 : elle surestime.
* Éprouvé par testChargerWithoutCarIsArbitrated, vérifié échouant sans le garde-fou (3 680 W
alloués au lieu de 6 000). Suite complète : simulation 29/29, charging 16/16, loadmodel
18/18, spotmarket 7/7. amd64 0 erreur / 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Fri, 28 Aug 2026 14:20:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm24) trixie; urgency=medium
* UNE BORNE SANS VÉHICULE BRANCHÉ EST HORS ARBITRAGE. Trouvé au banc en exerçant +etm23 :
une wallbox en Eco, voiture assignée, mais pluggedIn: false, s'est vu allouer 4 853 W
qu'elle ne pouvait pas tirer (currentPower: 0, charging: false). La PAC derrière elle n'a
survécu que sur son propre recrédit. Trois cycles consécutifs, 2 350 / 4 853 / 5 464 W
retirés du budget et donnés à personne.
* La cause est un filtre d'entrée incomplet : evParticipatesInWaterfall() reprenait celui du
proxy — voiture assignée, mode non manuel — sans pluggedIn. Le proxy n'en avait pas
besoin, son allocation n'étant pas une cascade : gaspiller ne privait personne. En cascade,
si. L'asymétrie était déjà dans le code — la boucle EV du scheduler, qui sert les
financements réseau, teste pluggedIn depuis toujours.
* Hors arbitrage, et non « servie à 0 avec un motif » : une borne débranchée ne consomme
rien, sa consommation nulle est déjà au compteur racine, et lui donner un rang la ferait
figurer dans la cascade pour n'y rien faire. Même traitement que les deux autres
exclusions, journal compris.
* Défaut de 3g-1, révélé par 3g-2 parce que la décision atteint enfin le matériel — c'est le
cinquième de cette famille, et le premier trouvé par la machine plutôt que par les tests.
Détail et mesures : docs/RELEVE_3g2.md §3.1.
* Éprouvé par testUnpluggedChargerTakesNoBudget, vérifié échouant sans le correctif. Suite
complète : simulation 28/28, charging 16/16, loadmodel 16/16, spotmarket 7/7.
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 27 Aug 2026 13:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm23) trixie; urgency=medium
* 3g-2 — UNE CHARGE, UN COMMANDEUR. C'est le défaut le plus grave qu'avait révélé le passage
machine du 2026-08-26 : adjustEvChargers() re-décidait le surplus APRÈS le waterfall et
passait outre le refus de l'arbitre. La décision de l'arbitre n'atteignait donc RIEN — elle
était publiée, juste, et une milliseconde plus tard le proxy commandait autre chose. Corrigé
des deux côtés : les actions EV du waterfall sont dispatchées vers EvAdapter::applyAction()
(elles ne l'étaient pas du tout), et les bornes ainsi commandées sortent du chemin proxy par
deux crochets virtuels posés dans l'amont. Le jeu des bornes commandées est NOMMÉ par le
scheduler (Plan::waterfallEvIds), jamais redéduit d'un motif ou d'un financement.
* evFloorW() ne multiplie plus par acquisitionTolerance. Ce sont deux grandeurs différentes,
confondues en écrivant la fonction : le plancher est ce que la borne EXIGE pour démarrer, la
tolérance est la part de ce plancher qu'on accepte de ne pas couvrir. Le banc a montré le
coût de la confusion : 2 070 W annoncés pour une borne qui en exige 4 140, waterfall qui
alloue 2 828 W, adaptateur qui refuse d'enclencher sous 6 A, et 2 828 W retirés du budget
pour personne.
* NOUVEAU MOTIF EV_GRID_START {budgetW, floorW, gridW} — la tolérance devient VISIBLE. Une
borne peut démarrer en soutirant au réseau ; le comportement existe depuis toujours chez le
proxy, où rien ne le nommait. L'action porte funding=Grid et sa comptabilité se PARTAGE :
budgetW dans budget.allocatedW, gridW dans budget.evReservedW, budgetW + gridW ==
allocatedW. Brèche assumée dans le financement réseau que DESIGN_3g §3.1 déférait à 3f, et
bornée par construction : surplus réel exigé, au plus UNE borne par cycle, sous le plafond
de la protection de surcharge. Détail : docs/DESIGN_3g.md §3.1-bis.
* NOUVEAU MOTIF PHASE_LIMIT {limitW, requiredW} — quand c'est la limite de phase qui interdit
et non le budget. Les deux phrases envoient l'installateur vers des gestes opposés.
* LE RANG D'UNE BORNE EST CONFIGURABLE, et il vit là où vivent tous les rangs :
LoadConfig.priority (LM-1206/LM-1207). Pas de second système de priorité — un classement
propre aux bornes ne saurait pas exprimer « VE1 > ECS > VE2 ». Toute borne détectée reçoit
d'office une entrée LoadConfig de mécanisme « evcharger », portant le rang et RIEN d'autre :
ses limites viennent du Thing et changent avec la voiture branchée. Création journalisée,
une ligne par borne, à la création seulement.
* CORRIGE UNE RUPTURE D'INVARIANT DE +etm21/+etm22, non signalée à l'époque : en faisant
entrer les bornes dans loads[], 3g-1 publiait des charges qui ne figuraient dans AUCUNE
GetLoadConfig. C'est exactement la charge fantôme que le lot B-bis avait supprimée,
réapparue par l'autre bout — et l'app s'appuie sur « loads[] ⊆ GetLoadConfig » pour résoudre
libellé, domaine et rang. L'auto-provisionnement le rétablit par construction.
* L'entrée de configuration d'une borne SURVIT à la disparition de son Thing : borne
remplacée, ThingId changé, appareil déposé. Elle reste publiée available: false /
faultCode: THING_MISSING / motif LOAD_UNAVAILABLE, et ne retient aucun budget. La supprimer
d'office rendrait « borne remplacée » indistinguable de « borne jamais configurée ».
* Tri du waterfall rendu TOTAL : à rang égal, l'identifiant départage. Deux bornes à
priority = 100 partageaient la même clé et std::sort n'est pas stable — laquelle démarrait
n'était pas reproductible d'un cycle à l'autre.
* CHANGEMENT DE COMPORTEMENT — la réserve de stockage ne mordait que sur les AUGMENTATIONS.
Le recrédit anti-clignotement rendait à chaque charge sa propre consommation, y compris sous
la réserve : une charge en marche le restait, pendant que le motif publiait « aucune charge
n'est servie ». Sous la réserve le budget est une décision de retenir, pas une mesure : le
recrédit y est supprimé (LM-1203-b, « annulé, pas réduit »), et withheldW chiffre désormais
les deux termes retenus — le surplus annulé ET la consommation propre de la charge.
* Le nombre de phases visé est décidé avec la voiture branchée. canSetPhaseCount() ne dit que
« la borne porte l'état desiredPhaseCount » ; la vraie question est tranchée par le manager
(canSwitchPhaseCount, min/maxPossiblePhaseCount). Les deux avaient divergé en 3g-1, et une
borne monophasée se voyait viser 3 phases — plancher publié triplé, et sous surcharge 3 × 6 A
commandés là où le proxy commandait 1 × 6 A. La référence de la bascule redevient aussi le
courant POSSIBLE (courant tiré + surplus) et non le courant tiré.
* L'écrêtage par la limite de phase, perdu en 3g-1 avec planSurplusCharging(), est rétabli —
et DÉCLARÉ, dans LoadContext.declared.maxPowerW : le waterfall ne peut plus allouer au-delà
de ce que le branchement supporte. Il ne descend jamais sous le plancher de la borne, parce
que couper est le geste de la couche L4 et non celui de la stratégie.
* [upstream-fix] SetChargingInfo annonçait « Only given properties will be set, others will be
untouched ». C'est FAUX : l'objet remplace le stocké, et un champ writable omis retombe sur
son défaut — targetPercentage à 80, repeatDays vide, endDateTime absent — sous
EnergyErrorNoError. Constaté sur le banc le 2026-08-26. La description dit désormais la
vérité, avec la liste des défauts et le geste correct (lire, modifier, renvoyer complet), et
un test épingle le comportement qu'elle annonce.
* [upstream-fix] prepareInformation() repliait sur ChargingModeEco tout mode sans échéance
valide, alors que le repli ne concerne que les modes qui en EXIGENT une. Deux conséquences :
ChargingModeEcoWithMinCurrent était réécrit et persisté au cycle suivant — le mode « éco +
courant minimum garanti » ne survivait jamais, et la branche d'adjustEvChargers() qui
l'applique était morte ; et ChargingModeEco était « replié » sur lui-même à chaque cycle,
avec écriture disque et notification chargingInfoChanged sans qu'aucune valeur ne change.
* Tests : la suite de simulation ne COMPILAIT plus depuis 639689b (constructeur
EtmVariableLoadAdapter, paramètre minPowerW non répercuté sur cinq sites d'appel) — donc
plus personne ne l'exécutait. Réparée, puis étendue. Le catalogue de motifs avait dérivé au
passage : BATTERY_RESERVE et BELOW_MIN_POWER étaient entrés sans ligne de rendu attendue,
donc leur TEXTE — celui sur lequel le banc s'appuie — n'était éprouvé par rien.
* Suite complète verte : simulation 27/27 (dont les 16 scénarios `run`), charging 16/16,
loadmodel 16/16, spotmarket 7/7. Build amd64 0 erreur / 0 warning. RIEN N'A TOURNÉ SUR
MACHINE : voir docs/RECAP_3g2.md pour ce qui reste à éprouver au banc.
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 27 Aug 2026 11:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm22) trixie; urgency=medium
* 3g-1, CE QUI MANQUAIT — les bornes étaient bien arbitrées, mais elles ne franchissaient
pas la frontière RPC. buildTelemetry() n'interrogeait que la table des charges de
configuration et sautait l'EV avec un commentaire devenu faux (« décidé en amont du
waterfall »). Résultat : le journal montrait les décisions des deux bornes, et
GetLoadTelemetry n'en montrait aucune. Le changement de contrat annoncé par +etm21 n'était
donc PAS livré, et le critère LM-1204-b était inéprouvable — son discriminant est le MOTIF
publié, précisément ce qui n'arrivait pas. Attrapé au premier passage machine.
* NOUVEAU CHAMP loads[].funding — « surplus » ou « grid ». Il devient nécessaire dès lors
que les bornes sont dans loads[] : une borne servie par la cascade est comptée dans
budget.allocatedW, une borne servie par l'échéance ou le tarif dynamique SOUTIRE au réseau
et vit dans budget.evReservedW. Sans ce champ, un client qui somme allocatedW trouve un
écart sans pouvoir dire s'il regarde un défaut ou une borne financée au réseau. Omis en
mode dégradé : sans plan, il n'y a pas de financement.
* Les bornes figurent aussi dans la télémétrie DÉGRADÉE. C'est là que savoir qu'une borne
existe et n'est plus pilotée vaut le plus ; l'omettre ferait lire « pas de borne ».
* Une borne SANS action reste hors du contrôle « charge arbitrée sans décision » : sans
voiture assignée ou en mode manuel, elle est hors arbitrage et n'en recevoir aucune est
correct. Avertir à chaque cycle sur une borne simplement débranchée noierait le signal.
* Rendu du motif BATTERY_RESERVE : « batterie à 50 %% » s'affichait tel quel. « %% » est une
échappée printf ; QString::arg ne l'interprète pas. Vu sur le banc à la première
observation de la règle.
* CONSTAT, non corrigé ici : le RANG d'une borne n'est pas configurable — descriptor() fixe
priority = 100 pour toutes. Deux bornes ont donc la même clé de tri, et std::sort n'est pas
stable : la cascade est correcte (une seule démarre, l'autre reçoit son motif) mais
LAQUELLE n'est ni réglable ni garanti reproductible. Demande un lot dédié, et ne doit pas
être présenté comme un réglage dans l'app avant.
* Code mort retiré : buildMinCurrentAction() et buildIdleAction() n'étaient plus appelés
depuis +etm21 mais restaient compilés, seuls porteurs de EV_ECO_MIN et EV_IDLE. Les trois
codes retirés (avec EV_SURPLUS) restent DÉCLARÉS et rendus pour qu'un journal antérieur ou
une box en retard sur son app reste lisible — mais plus rien ne les produit.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 19:40:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm21) trixie; urgency=medium
* 3g-1 — LA TRANSPLANTATION. Les bornes de recharge entrent dans le waterfall : elles
partagent le budget avec l'ECS et la PAC, décrémentent la cascade, et portent enfin un
rang qui a un sens. Il était décoratif depuis le début.
* Le défaut que cela corrige n'était pas théorique. planSurplusCharging() calculait
l'allocation UNE fois, avant la boucle, et ne la décrémentait jamais : chaque borne
recevait la TOTALITÉ du surplus, comme si elle était seule. Avec 4 kW et deux bornes, ce
n'était pas « 2 kW à chacune » mais « 4 kW à chacune » — et ce qui rattrapait n'était pas
l'arbitrage mais le compteur au cycle suivant, puis la protection de surcharge. Le système
convergeait par correction d'erreur, pas par décision.
* Trois motifs EV disparaissent, et ce sont des suppressions, pas des remplacements :
EV_SURPLUS devient SURPLUS_SETPOINT, EV_ECO_MIN devient le PLANCHER du §12 — la même idée
exprimée une seule fois pour tous les mécanismes — et EV_IDLE cède à BELOW_MIN_POWER /
SURPLUS_INSUFFICIENT, qui DISENT pourquoi la borne ne charge pas.
* Le nombre de phases est décidé AVANT l'allocation et réutilisé à l'exécution, parce que
c'est lui qui fixe le plancher : 6 A valent 1,4 kW en monophasé et 4,1 kW en triphasé.
Publier le plancher du nombre COURANT alors que l'exécution bascule vers un autre
produirait un défaut silencieux — le waterfall allouerait 2 kW en croyant démarrer une
borne qui en exige 4,1, et se croirait servi.
* La conversion watts → ampères descend dans l'adaptateur (règle 4 : l'adaptateur écrête,
il ne décide pas). Deux origines coexistent : le proxy remplit currentA, la cascade
remplit powerW. Le discriminant est currentA.
* evReservedW (correction A) est RESTREINT au financement réseau au lieu d'être supprimé :
une borne servie par la cascade décrémente déjà le budget par son allocation, la réserver
en plus la compterait deux fois. Échéance et spot market restent décidés en amont, donc
restent réservés.
* Restent au proxy, délibérément : l'échéance de départ (sa machinerie vient du SoC de la
voiture et n'a aucun équivalent ailleurs ; et la tenir revient à SOUTIRER, donc à franchir
la frontière du §10), le spot market, la protection de surcharge, l'estimation de SoC.
Le proxy maigrit, il ne disparaît pas — c'est une étape assumée, pas un provisoire.
* Bornes sans voiture assignée ou en mode manuel : HORS arbitrage, pas rang 0. Leur
consommation est déjà au compteur racine, donc déjà dans le budget.
* ⚠️ CHANGEMENT DE CONTRAT — loads[] de GetLoadTelemetry contient désormais les bornes, et la
somme des allocations retombe sur budget.allocatedW, ce qui n'était pas vrai avant.
* Correction du motif BATTERY_RESERVE, attrapée à sa PREMIÈRE observation en vrai : il était
publié même quand rien n'avait été retenu (surplus négatif), disant « une règle vous
bloque » là où la vérité était « il n'y a pas de surplus » — exactement la confusion qu'il
existe pour supprimer, retournée. Il est désormais conditionné à withheldW > 0.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 18:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm20) trixie; urgency=medium
* 3g-1, première moitié — la prise en compte du stockage monte dans le budget. Elle vivait
dans planSurplusCharging(), donc dans le chemin VE, par accident d'histoire : ce n'a jamais
été une règle « véhicule » mais une règle de BUDGET. Tant qu'elle y restait, une batterie
sous sa réserve empêchait de charger la voiture et laissait le chauffe-eau se servir.
* ⚠️ CHANGEMENT DE COMPORTEMENT pour l'ECS et la PAC, qui n'ont jamais connu cette règle.
Une installation qui servait ses charges sous le seuil de réserve ne le fera plus.
* ⚠️ DÉFAUT D'USINE ABAISSÉ : batteryLevelConsideration passe de 0,9 à 0,2. Le réglage
confondait une CIBLE et une RÉSERVE — une cible est ce qu'on aimerait atteindre, une
réserve ce sous quoi on ne descend pas, et la règle qu'il pilote a une sémantique de
réserve. Tant qu'elle ne touchait que le VE, 0,9 se défendait ; depuis qu'elle gouverne
toute l'installation, 0,9 gèle tout sous 90 % de SOC : ni ECS, ni PAC, ni borne. Chez un
client cela se manifeste comme un HEMS qui ne pilote plus rien, sans erreur et sans panne —
le pire mode de défaillance, parce qu'il ne se signale pas. Les installations existantes
conservent leur valeur enregistrée ; seul le défaut change.
* Motif BATTERY_RESERVE (socPercent, reservePercent, withheldW) — troisième terme de la
distinction déjà portée par BELOW_MIN_POWER : il n'y a rien / il y en a mais pas au bon
format / il y en a et une RÈGLE l'interdit. Sans lui, une installation qui exporte en plein
soleil n'enclenche rien et publie « surplus insuffisant » : l'installateur cherche une panne
qui n'existe pas. withheldW dit combien de surplus a été retenu — c'est ce chiffre, et non
le SOC, qui rend la cause lisible.
* Sous la réserve, le budget est ANNULÉ et non réduit (LM-1203-b). Servir « un peu »
retarderait la recharge du stockage sans démarrer personne, puisque les planchers du §12
mordent d'abord.
* ⚠️ CHANGEMENT DE CONTRAT — BATTERY_RESERVE est un code nouveau : un client sans clé de
rendu affichera son repli.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 16:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm19) trixie; urgency=medium
* §12 suite — BELOW_MIN_POWER étendu aux deux branches qui connaissaient la cause précise
et publiaient la générique. La distinction est « il n'y a pas assez » contre « il y en a,
mais pas au bon format », et elle décide du geste : attendre le soleil, ou relire une
fiche technique.
* Branche à paliers (relay-router, etmvariableload fixed) : un budget de 300 W devant un
premier palier de 500 W n'est pas un manque de surplus, c'est une granularité. Elle
publiait SURPLUS_INSUFFICIENT ; elle publie désormais BELOW_MIN_POWER avec le palier
hors d'atteinte.
* Branche sg-ready : elle publiait SG_NORMAL — correct sur le fond, mais son rendu français
disait « Surplus insuffisant (933 W) » devant un état 3 à 1 500 W. Le code publié n'a
jamais trompé l'app ; c'est l'installateur qui lit journalctl pendant une mise en service
qui l'était. Un budget positif sous l'état 3 donne maintenant BELOW_MIN_POWER, avec le
paramètre state qui NOMME l'état hors d'atteinte : dire « sous le plancher » sans dire
lequel remplacerait une phrase vague par une autre.
* SG_NORMAL ne sort donc plus que pour un budget nul ou négatif, et sa phrase devient
« Aucun surplus à allouer » — vraie dans ce seul cas, là où « surplus insuffisant »
couvrait aussi, et à tort, un budget de 933 W.
* SURPLUS_INSUFFICIENT est conservé tel quel pour budget ≤ 0 : il reste juste quand il n'y a
réellement rien.
* ⚠️ CHANGEMENT DE CONTRAT — BELOW_MIN_POWER est un code nouveau, et son paramètre state est
optionnel. Un client sans clé de rendu affichera son repli.
* Le cas EV porte le même défaut (EV_IDLE dit « pas de surplus » là où la borne a un plancher
de 6 A), mais son site est le proxy amont, que la transplantation 3g remplace. Corrigé là
plutôt qu'ici, sans quoi on écrirait deux fois la même règle.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 14:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm18) trixie; urgency=medium
* Lot E §12 — le plancher de modulation. Le domaine d'une consigne cesse d'être
[0, maxPowerW] pour devenir {0} ∪ [minPowerW, maxPowerW], un ensemble DISCONTINU —
parce que c'est ce que fait le matériel : une borne ne charge pas sous 6 A, une PAC n'a
pas d'« état 3 à moitié ».
* LM-1203, la règle qui fait le travail : sous le plancher, la charge reçoit 0 W et le
budget PASSE à la suivante. Sans elle, une charge de rang 1 à plancher élevé retiendrait
en otage un budget qu'elle ne peut pas utiliser, et tout le waterfall en aval resterait à
sec pendant qu'elle-même ne démarre pas.
* Le plancher est DÉCLARÉ pour etmvariableload dynamic et DÉRIVÉ ailleurs — plus petit
palier non nul chez relay-router, plus petite estimation non nulle chez sg-ready. Même
règle que stagesW au lot D : ne pas déclarer ce qui se dérive. SetLoadConfig refuse donc
o:minPowerW sur relay-router et en mode fixed, et le refuse aussi s'il dépasse maxPowerW —
le domaine serait réduit à {0} et la charge ne démarrerait jamais sans que rien ne dise
pourquoi.
* Motif BELOW_MIN_POWER (params budgetW, minPowerW), distinct de SURPLUS_INSUFFICIENT.
Les confondre enverrait diagnostiquer un manque de soleil là où il faut lire une fiche
technique : le budget peut être confortable et rester sous le minimum de CE matériel.
C'est ECS-309 appliqué — le motif nomme le mécanisme qui a produit l'issue.
* Le waterfall satisfaisait DÉJÀ LM-1203 pour les charges à paliers : le plus haut palier
≤ budget vaut 0 quand le budget est sous le premier palier, et le budget cascade. Le trou
n'était que dans la branche continue, où qBound(0, budget, max) acceptait n'importe quelle
miette.
* tests/auto/loadmodel : 15/15 en 3 ms.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 13:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm17) trixie; urgency=medium
* Lot E §11 — compteur et sonde par charge. Deux champs optionnels de même forme sur
LoadConfig : o:meterThingId et o:sensorThingId. Ils décrivent ce qu'on OBSERVE d'une
charge, jamais ce qu'on lui commande.
* L'arbitre ne s'en sert pas pour décider, et c'est vrai par CONSTRUCTION : les adaptateurs
ne voient même pas ces Things. Ils sont résolus au moment de publier la télémétrie
(attachMeasurements()), et nulle part ailleurs — la mesure ne traverse aucun chemin
d'arbitrage.
* Le compteur VÉRIFIE, il ne budgète pas. La mesure n'est pas injectée au budget : elle est
en retard sur la commande (scrutation Modbus, moyennage du compteur) et l'y mettre brute
réintroduirait l'oscillation que le recrédit anti-clignotement a supprimée ; et la charge
est déjà comptée dans le compteur racine, dont celui-ci n'est qu'une décomposition.
measuredW est donc publié À CÔTÉ de allocatedW, jamais fondu dedans, sans quoi la somme
des allocations cesserait d'être réconciliable avec budget.allocatedW.
* La sonde est en lecture seule. Son usage immédiat est le diagnostic de mise en service ;
son usage durable est de constituer l'historique (température, puissance, heure) sans
lequel les seuils éco/confort du §10 ne se régleraient jamais autrement qu'au doigt
mouillé. C'est la raison pour laquelle ce champ existe AVANT d'avoir un consommateur.
* Ni le compteur ni la sonde ne sont exclusifs, contrairement à relays[]. LM-201 (« un canal
de commande, une charge ») vaut pour ce qui commande ; ceux-ci mesurent. claimedThingIds()
les exclut, l'exclusion est désormais commentée sur place pour qu'on ne la « corrige » pas,
et deux charges peuvent partager une sonde de pièce ou un compteur divisionnaire.
* Aucun contrôle d'existence à l'écriture : un ThingId encore inconnu est accepté. Refuser
imposerait un ordre aux opérations — déclarer le compteur avant la charge — que rien ne
justifie. Un id qui ne résout pas produit une absence de mesure, pas une config invalide.
* Omis quand vides, jamais une chaîne vide persistée — même règle que domain.
* tests/auto/loadmodel : 12/12, dont l'aller-retour des deux champs, leur absence de
claimedThingIds(), le partage d'un compteur entre deux charges, et le fait que les
rattacher ne reconstruit rien.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 11:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm16) trixie; urgency=medium
* Lot D — les constats de l'agent app, dans l'ordre de ce qu'ils coûtent.
* Renommer une charge atteint enfin l'adaptateur. updateSoftConfig() ne portait que
priority et needs : un changement de libellé seul était compté « inchangée », l'adaptateur
gardait son ancien nom, et le journal de la box contredisait GetLoadConfig jusqu'à la
prochaine reconstruction. Pour qui suit journalctl pendant une mise en service, les deux
sources se contredisaient. `label` entre dans la signature et dans le prédicat `soft` de
l'arbitre — sans entrer dans sameHardware() : c'est une mise à jour EN PLACE, pas une
reconstruction. La vraie prise était le Q_UNUSED de SgReadyAdapter, hérité d'avant le lot
B-bis : sans lui, renommer la PAC — le cas même qu'on allait tester — n'aurait rien changé.
* relays[] n'est plus comparé par index. Réordonner les mêmes relais aux mêmes puissances
reconstruisait la charge : contacts ouverts par l'état sûr, verrous minOn/minOff réarmés à
froid. Un installateur qui remonte une ligne dans l'app ne s'attend pas à couper sa charge,
et annuler son geste en coûtait une SECONDE. Les comparer comme un ensemble aurait été faux
dans l'autre sens : à somme égale la table retient la première combinaison rencontrée, donc
échanger deux relais de MÊME puissance change le contact qui sert ce palier — autre
contacteur qui s'use, autre résistance qui chauffe. sameHardware() compare désormais la
TABLE DÉRIVÉE : deux listes qui produisent les mêmes paliers avec les mêmes contacts sont
matériellement identiques, par construction et non par pari.
* Les paliers atteignables sont publiés — mechanism.stagesW, en télémétrie. Ils ne sont
déclarés nulle part : ils se dérivent des relais, et sans publication un client qui veut les
montrer doit réimplémenter la combinatoire. Seconde implémentation, divergence certaine à la
première correction d'un seul côté, et c'est le client qui aurait tort sans le savoir.
Publiés là et PAS dans GetLoadConfig, à dessein : la télémétrie n'a aucun chemin d'écriture,
donc « lecture seule » y est vrai par construction, alors que GetLoadConfig aurait cassé
l'aller-retour neutre dont l'app dépend pour écrire — elle renvoie verbatim ce qu'elle a lu,
le champ serait revenu dans SetLoadConfig, et un refus en bloc aurait emporté toutes les
charges.
* RelayRouter::deriveStages() : implémentation UNIQUE de la combinatoire, partagée par la
construction du routeur, la comparaison matérielle et la publication. C'était le fond de la
demande : une seule règle, donc pas de divergence possible.
* sameHardware() passe en public. Ce prédicat décide si une écriture coûte un geste MATÉRIEL
ou une mise à jour en place — un élément de contrat, que le client annonce à l'installateur
avant d'enregistrer. Statique et sans état.
* tests/auto/loadmodel — cible neuve, volontairement légère : aucun serveur nymea, 2 ms. Elle
a attrapé un défaut de la correction elle-même avant le banc : la première version de
RelayStageTable::operator== comparait les listes de ThingIds, donc voyait une différence
entre fermer {K1,K2} et fermer {K2,K1}. Même geste électrique, deux listes. Les contacts
d'un palier se comparent maintenant comme un ensemble.
* tools/repair_energylogs.py — répare les cumuls d'énergie empoisonnés par une lecture de
compteur absurde. N'est PAS un correctif de ce plugin : la cause est extérieure (SunSpec
lisant les float32 Fronius aux mots permutés, corrigé par etm-powersync-plugins-modbus
d08cf50, daté 05:30:14 UTC — la seconde même du premier échantillon empoisonné du banc). Ce
que le script rattrape est la SÉQUELLE : EnergyManagerImpl accumule sans contrôle de
plausibilité dans un compteur persistant, et à 7,5e33 kWh le plus petit incrément
représentable en double vaut 1,67e18 — le compteur n'était pas seulement faux, il était
GELÉ, et rechargé tel quel à chaque démarrage. Appliqué sur .75 : 62 726 anomalies → 0,
accumulation reprise et vérifiée en direct.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 08:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm15) trixie; urgency=medium
* La PAC entre en configuration : le bloc d'enregistrement codé en dur d'energypluginnymea.cpp
est SUPPRIMÉ, avec registerSgReadyAdapter(), la table m_sgReadyAdapters et claimedRelays().
Il n'existe plus aucun chemin d'enregistrement en dur : m_loadAdapters est la table unique
des charges arbitrées, et toutes ses entrées viennent de LoadConfigStore. La PAC du banc est
devenue une LoadConfig « sg-ready » ordinaire — nommable, classable, rangeable,
désactivable — comme n'importe quelle autre charge.
* Contrat : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig, et c'est un
invariant de CONSTRUCTION, pas une discipline. La règle de détection publiée à +etm14 — « un
loadId sans équivalent dans GetLoadConfig est une charge non configurable » — est RETIRÉE
d'INTERFACE.md : elle n'avait qu'un porteur, pac-terrain, qui vient de disparaître. Aucun
champ « configurable: false » n'a été ajouté : il n'aurait plus rien à signaler. Un client
qui a déjà implémenté la règle peut la garder sans dommage, elle ne se déclenchera plus.
L'inclusion ne vaut que dans ce sens : GetLoadConfig peut contenir des charges absentes de
loads[] — celles en enabled:false, exclues de l'arbitrage (contrat §9). Une charge configurée
absente de loads[] se lit « désactivée », jamais « perdue ».
* SÉCURITÉ — repli L2 d'une PAC de configuration : trou ouvert par LM-300, refermé ici. Le
repli du mode dégradé envoyait un Setpoint(0) à toute la table des charges pilotées et un
State(2) à une table séparée, réservée à la PAC codée en dur. Or une PAC construite depuis la
CONFIG vit dans la première table : elle recevait donc un Setpoint, que SgReadyAdapter
rejette d'entrée (kind non supporté) — et rien d'autre. Sous compteur muet, cette PAC-là
restait en état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2. Le défaut
était invisible : le motif DEGRADED_L2 apparaissait correctement dans la télémétrie, seule
l'ÉCRITURE manquait. Le repli lit maintenant supportedKinds déclaré par l'adaptateur et
refuse explicitement ce qu'il ne sait pas replier. Règle portée en LM-301-b : dès qu'un
mécanisme rejoint la table commune, tout code qui FABRIQUE une action doit lire
supportedKinds au lieu de présumer du kind à partir de la table où la charge se trouve.
* ECS-411 appliqué au SG-Ready : l'état de départ est RELU sur les contacts, plus supposé à 2.
Tant que la PAC était codée en dur et construite une fois au démarrage, l'hypothèse se voyait
peu ; depuis qu'elle vient de la configuration, elle est reconstruite à chaque changement de
matériel. Le vrai piège est le corollaire LM-104 : applyAction(état 2) sur un adaptateur qui
se CROIT en 2 est idempotent, donc muet, donc les contacts restent en 4 — l'hypothèse de
prudence devient un masquage de panne. ECS-411-b : le relevé publie ce qui a été LU, contact
par contact, avec l'issue — y compris quand l'issue est l'état neutre, qui est justement
celui qu'on vérifie après un démarrage — et passe en avertissement si un contact est
injoignable (supposé fermé, LM-104).
* ECS-412 appliqué au minStateHold : un m_lastSwitch nul vaut « commutation venant d'avoir
lieu », jamais « jamais commuté ». Sans cet armement, un redémarrage de nymead pendant que le
compresseur tourne autoriserait une coupure immédiate — du court-cycling provoqué par un
redémarrage de service, exactement ce que le verrou existe pour empêcher. L'armement est
PARESSEUX : applyAction() estampille au premier « now » reçu, refusé ou non, et le verrou
expire ensuite normalement. Jamais permanent — c'est le blocage circulaire corrigé côté
routeur le 2026-08-09, où une charge démarrant contacts ouverts ne pouvait plus jamais
s'enclencher. runtimeView() adopte la MÊME convention que lockWindow(), de sorte que
l'affichage ne peut pas contredire le comportement. Conséquence à connaître : toute
reconstruction d'adaptateur réarme le verrou pour sa durée configurée (900 s sur une PAC
réelle) — sans effet au banc, réel sur une installation.
* Les puissances SG-Ready du banc ne sont PAS des données d'installation. estimatedPowerW
{3: 1500, 4: 3000} et minStateHoldS 300 sont reconduits du bloc supprimé, où ils décrivaient
une PAC INVENTÉE ; aucune PAC n'est sous tension sur K1/K2 (polarité des contacts encore non
vérifiée). Ils sont marqués PROVISOIRES ET NON MESURÉS dans docs/TEST_TERRAIN.md §1.d, à
remplacer par la machine réellement câblée. Ce n'est pas cosmétique : estimatedPowerW sert de
base au recrédit budget et au choix d'état, une estimation fausse décale l'allocation de tout
le waterfall en aval, pas seulement celle de la PAC.
* La suppression du bloc en dur ne passe pas par applySafeState(), et ne le peut pas : c'est un
événement de COMPILATION, pas d'exécution — ECS-413 ne s'applique qu'à un adaptateur qui sort
d'une configuration vivante. Le filet est procédural et documenté (TEST_TERRAIN.md §1.d) :
poser la config sg-ready AVANT d'installer ce paquet, sur le +etm14 en cours d'exécution, de
sorte que K1/K2 ne soient jamais sans propriétaire. Installer d'abord et configurer ensuite
laisse une fenêtre où plus personne ne commande les contacts : ils restent dans leur dernier
état commandé, possiblement fermés. enabled:false ne met pas davantage une charge en
sécurité : il la retire de la configuration.
* TEST_TERRAIN.md §1.d : la recette « coller un bloc C++ puis rebuild » est morte — elle ne
compilait plus, et elle produisait une charge arbitrée que GetLoadConfig ignorait. Remplacée
par la déclaration JSON-RPC des deux charges du banc en un SetLoadConfig. Changer un palier,
un rang ou un ThingId ne demande plus de cycle build cross-arm64 → .deb → deploy.sh.
* Tests : testSgReadyRestartRecovery (état relu sur contacts : 4, neutre, contact injoignable →
avertissement ; relevé contact par contact avec l'issue ; et la remise au neutre qui ÉCRIT au
lieu de se croire idempotente). testSgReadySurplus et le waterfall partagé passent par le
chemin de PRODUCTION (SetConfigs → changed → rebuild), plus par un enregistrement direct, et
lisent l'état de la PAC par la frontière publique — la télémétrie est vérifiée en même temps
que l'arbitrage. Volet 0 ajouté : l'armement à froid gèle, puis EXPIRE. testLoadTelemetryRpc
vérifie le repli L2 sur les CONTACTS et le mécanisme publié, pas sur le motif : c'est
précisément ce que le motif seul ne prouvait pas.
* Harnais de test — JournalCapture se chaînait SUR ELLE-MÊME. qInstallMessageHandler() rend le
handler courant ; à la DEUXIÈME capture vivante, ce handler est collecte() elle-même, si bien
que chaque message se rappelait indéfiniment en concaténant dans m_texte : récursion infinie
à croissance quadratique, donc OOM. Une seule capture vivante ne déclenchait rien, et le seul
test qui en ouvre deux est celui ajouté par ce lot. Le handler n'est désormais installé
qu'une fois, chaîné au handler d'ORIGINE, et toutes les captures vivantes reçoivent le
message — ce que le chaînage visait. À rapprocher de la contrainte d'exécution portée dans
AGENTS.md : la suite de simulation ne se lance JAMAIS en un seul processus, un serveur nymea
complet étant instancié par test sans que rien ne soit rendu entre deux.
* Harnais de test — tm et owner non initialisés (simulation.cpp, trois sites) : freshSetup()
peut sortir sur un QVERIFY en échec sans les renseigner, et ils étaient déréférencés juste
après. Initialisés par l'appelant et vérifiés après l'appel : un échec de test lisible au lieu
d'un comportement indéfini.
* Vérifié sur le banc .75 avant publication (docs/RELEVE_LOTBbis.md) : l'ordre de migration
exercé dans le bon sens (config posée sur le +etm14, qui cède les contacts, PUIS déploiement),
GetLoadConfig et GetLoadTelemetry concordants, applySafeState confirmé sur les contacts réels
au retrait d'une charge, et le verrou d'armement à froid qui EXPIRE (5 min 40 puis état 4 sous
surplus abondant) — l'armement est paresseux, jamais permanent.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 25 Aug 2026 16:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm14) trixie; urgency=medium
* Contrat : loads[] de GetLoadTelemetry n'est PAS en correspondance 1:1 avec GetLoadConfig.
Une charge peut être arbitrée et publiée sans exister dans la configuration. Cas réel,
vérifié au banc : « pac-terrain » est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp
(contacts K1/K2 = Relay4/Relay5, états {3: 1500 W, 4: 3000 W}, minStateHoldS 300), donc
pleinement arbitrée — elle consomme du budget, porte des verrous, apparaît en télémétrie —
et pourtant inconfigurable : ni renommage, ni rang, ni domain, ni enabled:false. Un client
qui suppose la correspondance affichera une charge sans nom ni domaine, ou cherchera une
configuration inexistante. INTERFACE.md porte désormais la règle de détection : un loadId
de loads[] sans équivalent dans GetLoadConfig est une charge NON CONFIGURABLE — afficher le
loadId, la traiter comme non classée, ne proposer aucun écran de réglage. Aucun champ n'est
ajouté à la charge utile pour le signaler : « configurable: false » préjugerait du sort de
cette charge, qui n'est pas tranché.
* Aucun changement de comportement moteur. Seuls la documentation de contrat, le journal de
vérification et les tests bougent — le binaire est fonctionnellement identique à +etm13,
dont il ne se distingue que par ce changelog. La version est incrémentée parce qu'un
artefact publié doit se relire dans sa propre source : livrer +etm13 avec un changelog
embarqué différent de celui du dépôt aurait recréé, en petit, la divergence que ce lot
passe son temps à fermer.
* Test : runtimeView() est SUBSTANTIELLE pour les quatre adaptateurs, et c'est prouvé par
mutation. La pure virtuelle force à RÉPONDRE, pas à répondre quelque chose : un
« return LoadRuntimeView{}; » compilerait, passerait le dispatch, et sortirait de la
télémétrie sans bruit — le client verrait une charge sans mécanisme ni verrou,
indistinguable d'une charge libre. testRuntimeViewIsSubstantial() amène chaque adaptateur
dans un état non trivial puis exige les valeurs runtime réelles (palier appliqué, consigne,
état normé, pourcentage dérivé à 75 % — ni 0, ni 100, ni la consigne), les verrous en
secondes restantes ET leur expiration, et les codes de défaut THING_MISSING et
UNUSABLE_ENCODING. Deux mutants « vue vide » ont été introduits puis retirés : le test
échoue sur chacun.
* Le déploiement sur box entre au dépôt : etm-powersync-deploy/deploy.sh, après l'incident du
2026-08-25 où .75 a été rétrogradée de +etm12 à +etm9. Trois causes composées — un glob sur
un répertoire de dépôt qui accumulait quatre paquets périmés, un dpkg -i qui autorise la
rétrogradation par défaut, et aucune relecture de version après coup. La recette fautive
figurait aussi dans docs/TEST_TERRAIN.md §b ; elle est remplacée. Le verrou décisif est
l'ARCHIVAGE avant dépôt : la règle sudoers de la box porte elle-même un « *.deb » et ne peut
pas être resserrée sans root, mais avec un seul paquet dans le répertoire ce glob ne peut
plus désigner qu'un fichier — y compris pour un geste fait à la main.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 25 Aug 2026 14:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm13) trixie; urgency=medium
* Frontière de télémétrie d'arbitrage. Constat d'origine, vérifié au banc : 90 s d'écoute,
arbitre actif, ~2,9 kW produits et ~2 kW exportés — ZÉRO notification NymeaEnergy.*.
L'état runtime de l'arbitre (allocation, budget, verrous, paliers, disponibilité, motifs)
ne franchissait aucune frontière RPC : il vivait exclusivement dans qCInfo(dcNymeaEnergy).
Expliquer les décisions au client — promesse présente dans les deux versions de gamme —
n'avait donc aucun transport. Ajout de NymeaEnergy.GetLoadTelemetry et de sa notification
LoadTelemetryChanged, MÊME charge utile pour les deux.
* Aucune phrase composée par la box. La box transporte un CODE et des PARAMÈTRES ; la phrase
est fabriquée par le client, dans la langue de son utilisateur. Justification vérifiée :
la locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale), donc deux utilisateurs d'une
même box peuvent avoir deux langues — impossible à servir avec une phrase composée en C++ ;
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId, donc structurellement hors du
catalogue de traduction nymea : rien ne rattraperait une phrase française émise là.
* LoadAction::reason devient {code, params}. La ligne FRANÇAISE du journal est désormais
RENDUE à partir du code par une fonction de rendu unique (renderFr), et non maintenue en
parallèle : deux écritures parallèles divergeraient, et c'est le diagnostic terrain qui
deviendrait faux. Le catalogue de 19 codes est EXTRAIT des sites existants, pas inventé ;
les motifs d'ECS-309 et ECS-309-b sont repris mot pour mot. Prouvé par comparaison de
traces (1726 lignes de journal identiques en simulation) et par un test de rendu à table
d'or qui échoue au premier code ajouté sans phrase attendue.
* Le libellé de la charge n'entre PAS dans la charge utile RPC : c'est de la langue naturelle
saisie par l'installateur. Il est substitué au moment du RENDU, par l'appelant qui le
connaît. Le client résout le sien via GetLoadConfig.
* Les verrous sortent en SECONDES RESTANTES, pas en watts. lockMinPowerW/lockMaxPowerW sont
un artefact du mécanisme d'allocation, illisible pour un utilisateur. Ceci n'enfreint pas
ECS-306 : cette exigence interdit qu'un index de PALIER franchisse la frontière
d'ALLOCATION ; une valeur d'AFFICHAGE sortante est un autre chemin.
* Le mécanisme sort en union discriminée (relay | variable | sgReady), comme LoadConfig :
kind discrimine, chaque variante porte SA charge utile et seulement la sienne. Toutes les
clés internes optionnelles au schéma (règle LM-302). ILoadAdapter::runtimeView() est PURE
VIRTUELLE à dessein : un mécanisme ajouté qui hériterait d'un défaut vide sortirait de la
télémétrie sans bruit, et le client verrait une charge sans mécanisme ni verrou —
indistinguable d'une charge libre.
* budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : le waterfall rend
à chaque charge sa consommation de début de cycle avant d'arrondir (anti-clignotement,
correction B). Sans ce terme, l'identité serait fausse DÈS QU'UNE CHARGE CONSOMME, c'est-à-
dire dans le cas normal, et le client chercherait un écart inexplicable. Il rend aussi
lisible l'artefact de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle
→ recrédit du nominal commandé).
* Champs omis, jamais nuls (règle maison) : timestamp absent = aucun cycle d'arbitrage depuis
le démarrage — en forger un se lirait « cycle exécuté, rien à allouer », soit le mensonge
même que ce champ existe pour empêcher ; budget absent = mode dégradé L2, où la
planification est suspendue et où publier des zéros se lirait « rien à allouer ».
* Cadence : émission sur changement QUALITATIF ou variation d'allocation > 50 W, plus un
battement de cœur de 60 s même sans changement. C'est le battement qui rend un arbitre
FIGÉ détectable : les notifications continuent d'arriver tandis que le timestamp cesse
d'avancer. Sans lui, « installation stable » et « moteur arrêté » sont le même silence.
Toute notification relance le décompte : la cadence totale reste d'environ une par minute.
* GetChargingSchedules rend désormais degradedMode. Il était POUSSÉ en notification seule :
un client connecté après la bascule ne pouvait pas savoir dans quel état il se trouvait.
Règle : tout élément runtime a un Get* ET un *Changed, jamais une notification seule.
La bascule ET la sortie du mode dégradé publient aussi la télémétrie immédiatement.
* LoadConfig : champ optionnel domain, énumération FERMÉE (ecs · ev · heating · hvac ·
battery). PURE métadonnée d'intention — l'arbitre ne la lit pas, aucun comportement moteur
ne change ; elle enregistre le choix de l'installateur. Codes, jamais libellés. Valeur
absente = charge non classée : le plugin ne la range pas d'office, parce qu'un classement
inventé serait indistinguable d'un classement choisi. Valeur hors énumération rejetée en
bloc. Elle ne préempte pas la couche domaine à venir : elle en deviendra le discriminant.
* Règle 7-c, deux silences fermés : le retour idempotent du RelayRouter et celui du
SgReadyAdapter étaient MUETS, si bien que « consigne reçue, palier déjà bon » ne se
distinguait pas de « l'action n'est jamais parvenue à l'adaptateur » — et c'est justement
le cas ordinaire qu'on vient vérifier quand une charge ne bouge pas. Journalisés au même
niveau que la branche qui commute (7-d, symétrie).
* Tests : rendu français à table d'or (19 codes + repli d'un code hors catalogue),
télémétrie de bout en bout (notification non nulle, snapshot ≡ notification, union de
mécanisme entière, verrous en secondes, seuil qui ne mord pas sur 10 W, battement à
timestamp figé, reprise à froid du mode dégradé par les DEUX portes, contrôle mécanique
« zéro langue naturelle »), et domain (aller-retour, rejet hors énumération, charge non
classée, absence d'effet sur l'arbitrage). Suites : simulation 37/37, charging 46/46,
spotmarket 32/32.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 25 Aug 2026 12:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm12) trixie; urgency=medium
* ECS-411-b — la reprise du palier au démarrage s'annonce toujours, et publie ce qu'elle
a LU : l'état de chaque relais avec sa puissance déclarée, le palier retenu, la branche
de déduction et la somme lue. La correspondance exacte ne journalisait qu'au palier non
nul ; au palier 0 — le cas de l'armement minOff, celui qu'on vient précisément observer
— le journal était muet, et « relais lus, tous ouverts » ne se distinguait pas de
« reprise non exécutée ». Une reprise avec relais injoignable passe en avertissement :
le palier déduit est plausible mais la lecture est incertaine.
* Défaut de fond trouvé PAR ce relevé, et corrigé. La puissance nominale de chaque relais
était redéduite des encodages solo de la table de paliers, or la déduplication par
puissance peut évincer un encodage solo : avec un câblage 500/1000/1500, le palier 1500
est encodé {500,1000} et le relais 1500 n'apparaît seul nulle part. Sa nominale valait
0, donc un démarrage sur ce seul relais annonçait palier 0 pendant que 1500 W
circulaient — l'annonce en sous-estimation qu'ECS-411 existe pour interdire, que le
silence au palier 0 couvrait. La nominale est prise à la déclaration.
* Règle 7-c d'AGENTS.md : un silence ne doit jamais être ambigu. Journaliser l'issue, pas
seulement l'issue remarquable ; journaliser ce qui a été lu, pas seulement ce qui en est
déduit. Trois autres endroits signalés dans spec_ecs.md, non corrigés.
* Protocole de mesure (RELEVE_ECS306 §3.0) : un état nymea n'émet changed() que si la
valeur bouge, donc une grandeur forcée constante déclenche le watchdog L2 en 90 s et
fait mesurer le watchdog au lieu de l'arbitrage. Constaté au banc.
* ECS-410-b — l'issue de ClearLoadFault est journalisée, et par l'ARBITRE. Elle distingue
trois cas : défaut levé, aucun défaut à lever, adaptateur introuvable. C'est le seul
levier de reprise à distance : ECS-410 pose un verrou collant que seuls ce RPC ou une
reconstruction lèvent, et sans issue lisible l'opérateur ne sait pas si son geste a agi
— il relance, puis conclut que le système est cassé. Écrite au niveau de l'arbitre, la
ligne couvre les quatre adaptateurs d'un coup et ne peut pas être oubliée par un
adaptateur futur. ILoadAdapter::clearFault() rend désormais un bool plutôt que void : le
type de retour force chaque implémentation à répondre à la question, là où une consigne
de journalisation se serait oubliée à la cinquième.
* Règle 7-d d'AGENTS.md : un refus DOIT être au moins aussi visible que l'application
correspondante. Le refus par verrou minStateHold de SgReadyAdapter était en qCDebug quand
l'application, dix lignes plus bas, était en qCInfo — un resserrement de la journalisation
aurait fait disparaître le refus AVANT le succès, ne laissant au journal que les décisions
abouties. Or un refus est plus informatif : il dit qu'une décision a été prise et n'a pas
été exécutée. Les autres qCDebug du moteur ont été vérifiés symétriques.
* Retour idempotent du routeur (relayrouter.cpp:228) : DIFFÉRÉ, et en qCDebug le jour venu.
C'est le cas le plus fréquent — plusieurs milliers de lignes par jour — et l'ambiguïté y
est partiellement couverte, le scheduler journalisant sa décision et l'armement à froid
s'exécutant avant ce retour.
* Suite complète : 112 tests, 0 échec. Tous les correctifs sont vérifiés échouant sans eux.
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 13 Aug 2026 21:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm11) trixie; urgency=medium
* ECS-309-b — le motif ne nomme un verrou que si ce verrou a déplacé la consigne. Le
critère était « consigne > budget », vrai dès que le budget passe sous zéro à palier
nul : 0 > -389. Le motif annonçait alors « Verrou minOn — chauffe-eau maintenue à 0 W
(puissance engagée, budget -389 W) » — un verrou qui ne mordait pas, sur une puissance
engagée nulle, quand le soutirage était la seule cause. Le critère compare désormais la
consigne appliquée à la consigne VOULUE avant écrêtage : relevée → minOn, rabaissée →
minOff, inchangée → le budget. Les deux branches se lisent sur le même axe.
* Constaté au banc le 2026-08-13 sur +etm10, sept fois en quatre heures, toujours au
cycle suivant un palier 0. Le défaut est antérieur à ECS-309, qui a ajouté la branche
minOff après lui sans voir que la première capturait déjà un cas étranger.
* Il ne s'agissait pas que de lisibilité. La branche minOn étant évaluée en premier, ce
défaut masquait le motif d'armement à froid d'ECS-412 chaque fois que le budget était
négatif au redémarrage — soit au fond du creux, précisément l'instant où il faut
redémarrer pour observer minOff. La campagne aurait mesuré le mauvais verrou.
* Suite complète : 112 tests, 0 échec. Le cas est vérifié échouant sans le correctif,
où il reproduit le texte exact relevé sur la machine.
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 13 Aug 2026 08:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm10) trixie; urgency=medium
* ECS-309 — motif de décision fidèle au mécanisme. Lorsqu'un verrou, et non le budget,
produit le palier appliqué, le decisionReason nomme le verrou et la contrainte qu'il
impose. Le chemin minOff — celui de l'armement à froid d'ECS-412 — publiait
« Surplus insuffisant (7706 W) » alors que 7706 W étaient précisément disponibles :
la décision était juste, le motif accusait le budget. Il cite maintenant le budget
DISPONIBLE, pour couper court à l'explication budgétaire.
* Les trois causes sont distinguables à la lecture : budget réellement insuffisant,
verrou minOn maintenant un palier au-dessus du budget, verrou minOff empêchant
l'enclenchement. Les cas minOn et minStateHold (PAC) étaient déjà fidèles.
* La règle 7 d'AGENTS.md exigeait un motif non vide ; ECS-309 l'étend de la présence à
la fidélité, et exige que les tests portent sur le mécanisme annoncé plutôt que sur
la non-vacuité de la chaîne — un test « motif non vide » n'aurait rien vu.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 11 Aug 2026 10:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm9) trixie; urgency=medium
* Corrige le va-et-vient JSON-RPC cassé par l'union LM-300. GetLoadConfig sérialise
une charge via le méta-objet, donc toutes les propriétés déclarées — y compris un
« sgReady » VIDE sur une charge qui n'est pas SG-Ready. Le schéma SetLoadConfig
exigeant « states » dès que la clé est présente, nymea rejetait la requête AVANT le
handler : lire puis réécrire une configuration relay-router était impossible.
« states » et « minStateHoldS » passent en optionnels ; l'exigence réelle (states
non vide, état 2 présent) reste tenue par LoadConfig::isValid().
* Constaté au banc à la première tentative réelle de SetLoadConfig. Aucun test ne
rejouait le geste de l'app — lire, modifier un champ, réécrire. C'est désormais le
cas (testLoadConfigRpc §3bis, vérifié échouant sans le correctif).
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 10 Aug 2026 09:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm8) trixie; urgency=medium
* ECS-110-b — un Thing n'appartient qu'à UNE charge active. Le cas général n'était
pas couvert : deux charges de configuration pouvaient revendiquer le même relais
(déclaré à la fois dans la charge ECS et dans un état SG-Ready), soit deux
commandes contradictoires sur un organe et sa puissance comptée deux fois dans le
budget. LoadConfig::isValid() s'arrête au bord d'une charge ; la vérification
d'ensemble est portée par LoadConfigStore::validateSet().
* Deux doublons intra-charge également refusés : deux étages relay-router sur le même
Thing (le routeur les fusionnait en silence en annonçant le double de la puissance)
et un relais répété dans un même état SG-Ready. Un relais présent dans PLUSIEURS
états reste légitime — l'état 4 est l'union des états 1 et 3.
* Identifiants de charge dupliqués refusés : la seconde entrée était perdue en
silence à la construction. ThingIds normalisés via QUuid, « {uuid} » et « UUID »
désignant le même organe.
* Au chargement du fichier, une entrée en conflit est écartée plutôt que de perdre
toute la configuration : écarter une charge ne commande rien, la charger
commanderait faux.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 09 Aug 2026 15:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm7) trixie; urgency=medium
* SG-Ready configurable — première implémentation réelle de spec_loadmodel.md §3.
LoadConfig devient une union discriminée par mécanisme (LM-300) : le champ
`adapter` est le discriminant, chaque mécanisme a une charge utile et valide la
sienne. `sg-ready` porte states[] { state, relays[], estimatedPowerW } et
minStateHoldS ; les paliers de la PAC ne sont plus codés en dur.
* Une configuration sg-ready dépourvue de l'état 2 est REFUSÉE, pas construite :
l'état 2 est le plancher de repli du mode dégradé L2 et de la désactivation.
Les deux Q_ASSERT de SgReadyAdapter, absents du binaire release, sont retirés au
profit de ce refus et d'un drapeau m_usable (ECS-110).
* La PAC de configuration vit dans la même table que les autres charges : elle
hérite d'ECS-412 (reconstruction incrémentale) et d'ECS-413 (état sûr = état 2)
sans code dédié.
* Garde de double propriété : si une PAC de configuration vise les contacts de la
PAC de banc enregistrée en dur, cette dernière est retirée — deux adaptateurs sur
un même contact commanderaient en contradiction et compteraient la charge deux
fois dans le budget.
* spec_loadmodel.md 0.1.2 — LM-104 : supposer conservateur pour annoncer, tenter
systématiquement pour agir. Vaut pour tout mécanisme, pas pour l'ECS seul.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 09 Aug 2026 12:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm6) trixie; urgency=medium
* ECS-414 — l'échelle d'échec d'écriture (ECS-410) est généralisée à SgReadyAdapter et
EtmVariableLoadAdapter : suivi asynchrone des acquittements, échelle bornée, défaut
collant levé par NymeaEnergy.ClearLoadFault.
* Plancher propre à chaque adaptateur : consigne 0 W pour la charge continue, ÉTAT 2
pour la PAC SG-Ready — jamais « contacts ouverts », qui est une commande et peut valoir
le blocage selon l'encodage câblé.
* Le repli SG-Ready honore le même contrat d'atomicité que le chemin aller : il part du
motif de contacts RÉELLEMENT lu et ordonne les commutations par transientHarm, de sorte
que la récupération ne traverse pas le blocage. Il contourne minStateHoldS (900 s).
* Un contact injoignable est supposé FERMÉ pour la télémétrie (cohérence ECS-411) mais
TOUJOURS commandé : sans quoi l'hypothèse conservatrice masquerait l'échec.
* Test : testSgReadyPartialFailure. Suite 18/18.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 09 Aug 2026 21:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm5) trixie; urgency=medium
* ECS-410 — échec d'écriture relais : l'adaptateur suit désormais l'acquittement de
chaque executeAction, sans jamais bloquer le cycle (AGENTS règle 5). Échelle bornée
à trois barreaux — cible, retour arrière, arrêt total — puis état de défaut COLLANT,
levé seulement par le nouvel appel NymeaEnergy.ClearLoadFault.
* Pendant une transition, la télémétrie annonce max(palier précédent, palier cible) :
ne jamais annoncer moins que ce qui peut être appliqué (même direction qu'ECS-411).
* LoadContext.available ajouté — il gouverne l'ALLOCATION, pas la COMPTABILITÉ : une
charge en défaut ne reçoit plus de consigne mais reste comptée au budget.
OPTIMIZER_PROTOCOL.md mis à jour dans le même lot.
* ECS-413 — désactiver une charge (enabled: false) la laisse dans l'état SÛR de son
adaptateur, appliqué AVANT destruction : relais ouverts pour un routeur, 0 W pour une
charge continue, état 2 pour une PAC SG-Ready — jamais le blocage. Un rebuild sans
désactivation ne coupe rien (ECS-412 préservé).
* Tests : testEcsPartialFailure, testEcsDisableLeavesSafeState. Suite 17/17.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 09 Aug 2026 18:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm4) trixie; urgency=medium
* ECS-412 — correction d'un blocage circulaire du verrou au démarrage à froid,
constaté AU BANC (charge figée à 0 W sous 4 kW de surplus disponible).
Un horodatage de dernière commutation nul servait de sentinelle « écoulé = 0 »
à chaque cycle : toute charge relay-router démarrant relais ouverts avec
minOffS > 0 ne pouvait jamais s'enclencher, donc jamais commuter, donc jamais
valider son horodatage.
* L'armement du verrou devient PARESSEUX et TRANSITOIRE : posé au premier temps
de cycle reçu par applyAction(), il expire après sa durée configurée.
L'invariant « temps = paramètre, jamais l'horloge » est préservé.
* Test : testEcsColdStartLockExpires — palier 0 au départ, minOffS = 120,
budget 5000 W ; éteinte pendant le verrou, enclenchée après. Suite 15/15.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 09 Aug 2026 10:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm3) trixie; urgency=medium
* Étape 1 (specs/spec_ecs.md) — trois corrections de comportement sur les
charges pilotées à relais :
* ECS-306 : le budget décrémenté par le scheduler suit le palier RÉELLEMENT
applicable. La fenêtre de verrou est de nouveau exposée à l'arbitre, en watts
(lockMinPowerW/lockMaxPowerW) — aucun index de palier ne franchit la
frontière. Sans ce canal, les charges de priorité suivante recevaient un
résidu surestimé pendant toute la fenêtre minOn, donc soutirage réseau.
* ECS-411 : le palier courant est déduit de l'état réel des Things relais au
démarrage, au lieu de repartir de 0 — le moteur ne croit plus 0 W pendant que
le ballon chauffe.
* ECS-412 : rebuild incrémental des adaptateurs. Un SetLoadConfig qui ne change
qu'un rang ne détruit plus l'adaptateur, donc ne réarme plus les verrous : sur
un ballon thermodynamique à minOn de 300-600 s, réordonner ses priorités
depuis l'app pouvait faire court-cycler le compresseur. Au démarrage à froid,
le verrou est ARMÉ (défaut sûr), jamais purgé.
* Tests : testEcsBudgetUnderLock, testEcsRestartRecovery,
testEcsRebuildPreservesLock. Suite simulation 14/14.
-- Patrick Schurig <etm.schurig@gmail.com> Sat, 08 Aug 2026 14:40:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm2) trixie; urgency=medium
* Charge pilotée rév. 3 — frontière optimiseur (watt-pur) ↔ routeur (watts→relais, experience-plugin).
* RelayRouter : combinatoire watts→relais ; paliers DÉRIVÉS des relais (things "power"), verrous
minOn/minOff internes, off-before-on, agrégation currentPowerW (mesuré sinon nominal commandé).
* Optimiseur watt-pur : suppression du kind Stage ; buildSetpointAction (arrondi powerLevels / résidu).
* LoadConfig : forme relay-router (relays[]=[{thingId,powerW}] + minOnS/minOffS), validation
conditionnelle ; Get/SetLoadConfig (schéma SET "o:" pour les deux formes exclusives).
* Repli L2 unifié : compteur muet → Setpoint(0) force=true sur tous les ILoadAdapter (RelayRouter
coupe TOUS les relais). EtmVariableLoadAdapter (continu/triac) conservé.
* Tests : combinatoire/off-before-on/déduplication, repli L2 relais, chaîne config→routeur, RPC.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 28 Jun 2026 13:38:36 +0200
powersync-energy-plugin-nymea (1.15.2+etm1) trixie; urgency=medium
* Build contre nymea 1.15.2. EcsRelayAdapter + SgReadyAdapter dans init() (§1.d banc hems).
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 25 Jun 2026 15:30:00 +0200
powersync-energy-plugin-nymea (1.15.0+etm1) trixie; urgency=medium
* Renommage paquet powersync-* + version 1.15.0 alignée sur libnymea-energy cible hems
* EnergyArbitrator : waterfall ECS+PAC, RuleBasedScheduler, watchdog L2
* EcsRelayAdapter : 8 paliers binaires, off-before-on, verrous minOn/minOff
* SgReadyAdapter : 4 états SG-Ready, hystérésis kForceMargin=1.2, zone morte
* §1.d banc hems : enregistrement EcsRelayAdapter+SgReadyAdapter dans init()
* Build deb : tests exclus (CONFIG+=build_tests requis séparément)
-- Patrick Schurig / ETM PowerSync <etm.schurig@gmail.com> Wed, 25 Jun 2026 12:00:00 +0200
nymea-energy-plugin-nymea (1.13.0+etm1) jammy; urgency=medium
* Fork ETM PowerSync de nymea-energy-plugin-nymea
-- Patrick Schurig / ETM PowerSync <etm.schurig@gmail.com> Sun, 07 Jun 2026 21:38:25 +0200
nymea-energy-plugin-nymea (1.13.0) jammy; urgency=medium
[ Simon Stürz ]
* Update license text and add SPDX identifier
-- jenkins <developer@nymea.io> Tue, 02 Dec 2025 10:40:27 +0100
nymea-energy-plugin-nymea (1.12.4) jammy; urgency=medium
-- jenkins <developer@nymea.io> Thu, 27 Nov 2025 11:21:05 +0100
nymea-energy-plugin-nymea (1.12.3) jammy; urgency=medium
-- jenkins <developer@nymea.io> Tue, 04 Nov 2025 11:39:56 +0100
nymea-energy-plugin-nymea (1.12.2) jammy; urgency=medium
[ Simon Stürz ]
* debian: Fix debian packaging for Qt6 packages
-- jenkins <developer@nymea.io> Wed, 29 Oct 2025 12:29:18 +0100
nymea-energy-plugin-nymea (1.12.1) jammy; urgency=medium
-- jenkins <developer@nymea.io> Mon, 13 Oct 2025 15:29:39 +0200
nymea-energy-plugin-nymea (1.12.0) jammy; urgency=medium
[ Simon Stürz ]
* Add Qt6 support
* Fix phase switching available logic
-- jenkins <developer@nymea.io> Thu, 11 Sep 2025 14:27:01 +0200
nymea-energy-plugin-nymea (1.11.1) jammy; urgency=medium
[ Simon Stürz ]
* Stop charging if the battery is under the consideration point
* Fix wrong warning regarding configuration ENV loading
-- jenkins <developer@nymea.io> Thu, 22 May 2025 15:29:54 +0200
nymea-energy-plugin-nymea (1.11.0) jammy; urgency=medium
[ Martin Lukas ]
* Add support for houshold battery level consideration
[ Simon Stürz ]
* Update permissions in order to allow a non admin system user
* Improve overload protection
* Improve phase switching logic
* Fix invalid max phase count calculation on single phase car with
phase switching charger
-- jenkins <developer@nymea.io> Thu, 20 Mar 2025 14:17:07 +0100
nymea-energy-plugin-nymea (1.10.0) jammy; urgency=medium
[ Simon Stürz ]
* Add spot market charing
-- jenkins <developer@nymea.io> Tue, 26 Nov 2024 20:53:11 +0100
nymea-energy-plugin-nymea (1.9.5) jammy; urgency=medium
-- jenkins <developer@nymea.io> Mon, 21 Oct 2024 21:05:52 +0200
nymea-energy-plugin-nymea (1.9.4) jammy; urgency=medium
-- jenkins <developer@nymea.io> Wed, 14 Aug 2024 18:36:34 +0200
nymea-energy-plugin-nymea (1.9.3) jammy; urgency=medium
[ Simon Stürz ]
* Update debian packaging and compat level
-- jenkins <developer@nymea.io> Fri, 19 Jul 2024 21:07:54 +0200
nymea-energy-plugin-nymea (1.9.2) jammy; urgency=medium
-- jenkins <developer@nymea.io> Thu, 11 Apr 2024 15:47:40 +0200
nymea-energy-plugin-nymea (1.9.1) jammy; urgency=medium
-- jenkins <developer@nymea.io> Thu, 04 Jan 2024 19:14:46 +0100
nymea-energy-plugin-nymea (1.9.0) jammy; urgency=medium
-- jenkins <developer@nymea.io> Thu, 23 Nov 2023 09:55:12 +0100
nymea-energy-plugin-nymea (1.8.4) jammy; urgency=medium
[ Michael Zanetti ]
* Don't use acquisitiontolerance to determine wheter to switcht to 3
phases
* Fix minimum car amps not being regarded in ECO mode
-- jenkins <developer@nymea.io> Tue, 14 Nov 2023 09:12:06 +0100
nymea-energy-plugin-nymea (1.8.2) jammy; urgency=medium
[ Michael Zanetti ]
* Fix effective phase count after switching phases
-- jenkins <developer@nymea.io> Wed, 28 Jun 2023 09:49:36 +0200
nymea-energy-plugin-nymea (1.8.1) jammy; urgency=medium
[ Michael Zanetti ]
* Include car minimum amps in calculation for enoughFor3PhaseCharging
-- jenkins <developer@nymea.io> Tue, 13 Jun 2023 10:20:45 +0200
nymea-energy-plugin-nymea (1.8.0) jammy; urgency=medium
[ Michael Zanetti ]
* Add support for dynamically setting the phase count
* Use currentPower instead of current for calculating the allowance
* Don't drain batteries during daytime if solar power fluctuates
* Also regard minimum charging amps for the assigned car
-- jenkins <developer@nymea.io> Tue, 16 May 2023 16:53:01 +0200
nymea-energy-plugin-nymea (1.7.0) jammy; urgency=medium
[ Simon Stürz ]
* Make sure effective phase count will be evaluated in any case for
the charger
* Fix time calculation for immediately charging required
* Fix running tests in dpkg
[ Michael Zanetti ]
* Add a lockOnUnplug setting
-- jenkins <developer@nymea.io> Thu, 09 Mar 2023 15:13:24 +0100
nymea-energy-plugin-nymea (1.6.3) jammy; urgency=medium
-- jenkins <developer@nymea.io> Wed, 01 Feb 2023 12:19:29 +0100
nymea-energy-plugin-nymea (1.6.2) jammy; urgency=medium
-- jenkins <developer@nymea.io> Mon, 16 Jan 2023 14:37:17 +0100
nymea-energy-plugin-nymea (1.6.1) jammy; urgency=medium
[ Michael Zanetti ]
* Allow ECO mode without time restriction
* No notification for normal charging
-- jenkins <developer@nymea.io> Fri, 23 Dec 2022 11:33:56 +0100
nymea-energy-plugin-nymea (1.6.0) jammy; urgency=medium
[ Simon Stürz ]
* Introduce tests and simulations
[ Michael Zanetti ]
* Allow ECO mode without time restriction
* No notification for normal charging
-- jenkins <developer@nymea.io> Sat, 10 Dec 2022 14:56:05 +0100
nymea-energy-plugin-nymea (1.5.1) jammy; urgency=medium
-- jenkins <developer@nymea.io> Mon, 07 Nov 2022 12:49:43 +0100
nymea-energy-plugin-nymea (1.5.0) jammy; urgency=medium
[ Michael Zanetti ]
* Fix current power calculation for evchargers without meter
-- jenkins <developer@nymea.io> Mon, 17 Oct 2022 14:06:35 +0200
nymea-energy-plugin-nymea (1.4.1) jammy; urgency=medium
[ Michael Zanetti ]
* Fix a crash when the root meter is removed
* Fixes for the automatic SoC calculation
* Fix acquisition tolerance
-- jenkins <developer@nymea.io> Thu, 29 Sep 2022 20:11:39 +0200
nymea-energy-plugin-nymea (1.4.0) jammy; urgency=medium
[ Michael Zanetti ]
* Add deep linking data to plugged in push notification
* Add acquisitionTolerance setting
-- jenkins <developer@nymea.io> Mon, 22 Aug 2022 17:00:54 +0200
nymea-energy-plugin-nymea (1.3.1) jammy; urgency=medium
[ Michael Zanetti ]
* Fix SoC calculation
-- jenkins <developer@nymea.io> Mon, 11 Jul 2022 15:36:45 +0200
nymea-energy-plugin-nymea (1.3.0) jammy; urgency=medium
[ Michael Zanetti ]
* Count up manual SoC while charging
* Add translations
[ Simon Stürz ]
* Update business logic behavior
-- jenkins <developer@nymea.io> Fri, 01 Jul 2022 22:50:18 +0200
nymea-energy-plugin-nymea (1.2.0) jammy; urgency=medium
-- jenkins <developer@nymea.io> Tue, 07 Jun 2022 19:45:44 +0200
nymea-energy-plugin-nymea (1.1.0) jammy; urgency=medium
[ Michael Zanetti ]
* Add CharingState information to API
* Fix calculation of remaining charging time if SoC is higher than
TSoC
* Some fixes in the smart charging logic
-- jenkins <developer@nymea.io> Tue, 10 May 2022 12:47:01 +0200
nymea-energy-plugin-nymea (1.0.0) xenial; urgency=medium
[ Michael Zanetti ]
* Improve calculation for charging power
[ Jenkins ]
-- Jenkins <jenkins@nymea.io> Thu, 10 Mar 2022 17:55:05 +0100
nymea-energy-plugin-nymea (0.1) xenial; urgency=medium
[ Michael Zanetti ]
* Import code from nymea-experience-plugin-energy
-- Jenkins <jenkins@nymea.io> Wed, 15 Dec 2021 11:54:55 +0100