Même défaut que I-1, dans les avertissements de chargement ajoutés par Q7 : l'identifiant hors
règle et l'échéance illisible étaient cités en noquote(). Avant Q7, un client pouvait les
persister avec un saut de ligne (ni l'id ni l'échéance n'étaient bornés), et la lecture du
fichier fabriquait alors une fausse ligne de journal.
Ces valeurs passent par citee() : entre guillemets, échappées par QDebug (même rendu
qu'avant pour une valeur ordinaire). L'avertissement LIBELLÉ TROP LONG, qui cite l'identifiant
dans sa phrase, perd son noquote().
Test : testInheritedValuesAreEscapedInTheJournal charge un fichier dont l'id et l'échéance
valent "x\n[NymeaEnergy] FAUX" : les trois avertissements partent, citent la valeur échappée,
et aucun message n'ajoute de ligne commençant par [NymeaEnergy].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Le refus écrivait, en noquote(), la phrase détaillée du refus : elle cite mode, domaine,
rankOrigin, adaptateur, relays[].thingId, échéance et libellé tels que saisis. Un saut de ligne
dans l'un d'eux fabriquait une fausse ligne de journal (etm72 l'échappait par `<< why`).
Le journal ne porte plus que la phrase rendue depuis le motif (renderFr), comme SetTariff :
cause et endroit, un identifiant seulement s'il est bien formé. Échapper le détail aurait
suffi contre l'injection, pas contre le volume : relays[].thingId n'est pas borné.
La cause système d'un WRITE_FAILED, que seul le détail portait, est désormais écrite par
LoadConfigStore::commit() dans une ligne à part (chemin et erreur du fichier, aucune saisie).
Test : testSetLoadConfigRefusalIsPublished envoie dix refus dont un champ vaut
"x\n[NymeaEnergy] FAUX" et vérifie, message par message (JournalCapture::messages()), que le
refus est journalisé et qu'aucun message n'ajoute de ligne commençant par [NymeaEnergy].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Le refus garde EnergyErrorInvalidParameter et gagne, comme SetTariff, un
`fault` LOAD_CONFIG_REFUSED et des `faultParams` : une cause par motif
existant (ID, LABEL, DEADLINE, MODE, DOMAIN, RANK_ORIGIN, RANK_ORIGIN_AUTO,
ADAPTER, FIELD_*, SG_READY_STATE_2, ID_DUPLICATE, THING_SHARED,
RANK_DUPLICATE, WRITE_FAILED, STORE_UNAVAILABLE) et l'endroit (index, id
quand il est bien formé, field, state, indexes/ids, rank, length/max,
position). Jamais le libellé. Les deux champs sont déclarés optionnels dans
le schéma ; un succès rend exactement ce qu'il rendait.
- id : 1 à 128 caractères de [A-Za-z0-9._{}-], refusé à l'écriture ;
hérité hors règle, chargé tel quel et signalé.
- needs.dailyDeadline : vide ou HH:MM (00:00-23:59), refusé à l'écriture ;
hérité illisible, l'entrée est chargée, l'échéance retirée de la mémoire
(ni l'arbitre ni l'optimiseur ne la reçoivent) et signalée.
- Les bornes de saisie passent avant la réconciliation de rankOrigin, dont le
motif cite le libellé.
- appendConfig() : l'ajout par le moteur ne borne que l'entrée ajoutée.
- leftChars() coupe en caractères sans couper une paire de substitution ;
l'id d'un motif n'est plus coupé par QString::left().
- Au chargement, chaque libellé trop long est signalé, pas seulement le
premier ; loadconfigstore.h dit « n'écarte rien au chargement ».
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Le libellé part tel quel dans chaque GetPlan. Rien ne le bornait : une saisie de plus de
1 Mio suffisait à faire dépasser la limite de ligne du protocole. La borne arrête la
saisie en amont ; le refus d'émission du client (commit précédent) reste le filet.
- LoadConfigStore::validateLabels(), appelée en tête de setConfigs() : un libellé de plus
de 256 points de code Unicode → refus en bloc, rien n'est modifié ; SetLoadConfig rend
EnergyErrorInvalidParameter et journalise le motif en français, qui nomme la charge par
son id et donne la longueur, jamais le libellé. Placée AVANT isValid() parce que les
motifs suivants citent le libellé.
- Même partage que les rangs (LM-1209-a-bis) : load() ne l'applique pas. Un libellé plus
long déjà persisté est chargé tel quel, ni tronqué ni écarté, et signalé ; la première
écriture devra le raccourcir.
- Test : testLabelIsBoundedOnWriteNeverOnLoad (256 accepté, 257 refusé sans modification
en mémoire ni sur disque, configuration héritée conservée entière).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sept étaient des trous de classification ou de citation, sept sont réelles.
TROIS CITATIONS AJOUTÉES, portées par du code depuis longtemps :
LM-400 une machine physique est UNE charge → c'est l'exclusivité d'un Thing
dans validateSet() qui l'empêche d'être déclarée deux fois
LM-301-b « une charge utile, une branche de fabrique » vaut pour CONSTRUIRE,
pas pour ALIMENTER en actions → le repli L2 lit supportedKinds
LM-600 les codes d'adapter portent le nom du MÉCANISME, jamais du domaine
QUATRE PASSÉES EN CADRAGE : ECS-004/005 (invariant tenu par ABSENCE — aucun
identifiant de relais ne franchit la frontière, il n'y a rien à pointer), LM-404
(hors périmètre déclaré), LM-501 (justification de NE PAS bâtir les groupes tant
que la liste plate suffit).
RESTENT SEPT, et ce sont de vraies attentes : ECS-111, ECS-302, ECS-305, ECS-307
(les trois dernières déjà listées en « ce qui reste à éprouver », §9 de
spec_ecs.md), LM-401/LM-402 (facettes, couche non bâtie), LM-502 (plafond de
groupe, conditionnel).
ECS-111 mérite d'être remonté : ce n'est pas une attente, c'est un défaut vivant,
et sa conséquence dépasse ce que la spec en dit. Détail dans le rendu de session.
Bilan de l'index après cette passe : 34 « spécifiée seule » (dont 27 en
territoire non construit), 26 cadrage, 24 citées par le code, 17 par un test, 12
vérifiées sur machine. Le nombre de départ était 70.
Aucun changement de comportement. ci-quality 3/3, build 0/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Premier usage réel de l'index, et il donne un résultat inattendu : sur les 70
règles « spécifiée seule », une part notable n'est PAS un trou d'implémentation
mais un trou de CITATION. Vérifié en cherchant le CONTENU dans le code plutôt que
l'identifiant :
ECS-110-b un Thing n'appartient qu'à une charge active → validateSet()
LM-302-b l'exclusivité ne se valide pas dans une charge utile → idem
LM-1209-a-bis écarter et refuser ne sont pas la même fonction → validateRanks()
ECS-410-b l'issue de ClearLoadFault est journalisée par l'arbitre
LM-303 les verrous appartiennent au mécanisme, pas au domaine
LM-1208-c sans voiture, le nombre de phases vient de la borne
ECS-308 MaxRelays TRONQUE au lieu de refuser
Toutes portées par du code, aucune citée. L'index les comptait donc comme
spécifiées seules — il SOUS-DÉCLARE la couverture, exactement comme il peut la
surdéclarer. C'est la même limite que celle écrite en tête de la page générée
(« cité par n'est pas couvert par »), prise par l'autre bout : un identifiant
absent d'un commentaire ne dit rien de ce que le code fait.
Citations ajoutées avec \rule{}, l'alias posé hier — donc navigables du
commentaire vers la règle. Aucun changement de comportement : ce sont des
commentaires. 63 « spécifiée seule » au lieu de 70, 21 citées par le code au lieu
de 14.
ci-quality 3/3, build 0/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Les trois conditions, d'un bloc.
INSERTION EN QUEUE. provisionEvChargerConfig() posait rang min − 1, écrasé à 1 —
donc à égalité avec la charge que l'installateur avait posée là exprès. C'est
désormais 1 + max(rangs). Queue de LISTE et non de domaine : les domaines ne sont
pas contigus, donc « juste après le dernier ev » exigerait de pousser une charge
d'un autre domaine, ce que la condition 2 interdit. Et 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. Plus fidèle au
principe que la formulation d'origine.
UNICITÉ. validateRanks() refuse une écriture où deux charges portent le même
rang. Avec la mise en garde écrite noir sur blanc : l'égalité ne coûte pas le
déterminisme — le tri est total sur (rang, identifiant) depuis 3g-2 — elle coûte
la gouvernabilité. S'y tromper ferait chercher le défaut dans le tri.
ET LA RÈGLE GÉNÉRALE QUI EN SORT (LM-1209-a-bis) : une vérification qui ÉCARTE et
une vérification qui REFUSE ne peuvent pas être la même fonction. validateSet()
nomme ce qui rend l'ensemble dangereux et load() s'en sert pour écarter ; 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, soit ECS-413 rouvert par une autre porte. Les deux sont
séparées ; validateRanks() n'est appelée que par setConfigs(). Au chargement, un
doublon hérité est signalé bruyamment et CONSERVÉ.
o:rankOrigin. Énumération fermée "auto" | "user", absente par défaut. Une
énumération et non un booléen parce que le troisième état est le point :
`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 » ; un booléen ne sait pas se taire. Même motif que measurement.source, et la
spec le nomme désormais comme un motif de conception plutôt qu'une décision au
cas par cas.
Et la précision demandée en écrivant : l'effacement porte sur le RANG, jamais sur
la réception d'une écriture. SetLoadConfig remplace en bloc, donc réordonner une
AUTRE charge renvoie forcément l'entrée de celle-ci — l'effacer à cette occasion
perdrait une marque encore vraie. L'omission ne lève rien ; pour lever sans
déplacer, un client envoie "user". Un "auto" fabriqué est refusé, l'aller-retour
verbatim reste licite.
Trois tests. testAutoProvisionedRankGoesToTheQueue vérifié échouant avec
l'insertion en tête — et il échoue en disant que la borne n'entre PAS en
configuration, les deux mécanismes se répondant : le rang libre par construction
et le refus d'écriture en filet. testRankOriginFollowsTheRankNotTheWrite couvre
le cas qui se serait perdu, un client naïf qui omet la clé en déplaçant une autre
charge. testRankUniquenessRefusesWriteButNeverDiscards couvre la moitié qui ne se
voit pas : qu'un chargement conserve ce qu'il aurait pu écarter.
amd64 0 erreur / 0 avertissement. simulation 31/31, charging 16/16, loadmodel
20/20, spotmarket 7/7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le cas général n'était pas couvert, et le trou était complet. isValid() s'arrête au bord
d'une charge : deux charges de configuration pouvaient revendiquer le même Thing — un
relais déclaré à la fois dans la charge ECS et dans un état SG-Ready — avec exactement
les conséquences que la garde de l'arbitre évite pour la PAC codée en dur : deux
commandes contradictoires sur un organe, sa puissance comptée deux fois dans le budget
(règle absolue 1). Rien ne l'empêchait, rien ne l'aurait signalé.
L'exclusivité est une propriété de l'ENSEMBLE, qu'aucune charge ne peut vérifier seule :
LoadConfigStore::validateSet(), appelée par setConfigs() (rejet total, rien persisté) et
au chargement (l'entrée en conflit est écartée, le reste survit — écarter une charge ne
commande rien, la charger commanderait faux).
Deux doublons intra-charge manquaient aussi : deux étages relay-router sur le même Thing,
que le routeur fusionnait en silence en annonçant le double de la puissance réelle, 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.
Portée tranchée : seules les charges enabled sont confrontées — une charge désactivée ne
construit aucun adaptateur, et l'interdire empêcherait de préparer un remplacement ;
activer repasse par setConfigs(). ThingIds normalisés via QUuid, la configuration
mélangeant « {uuid} » et « uuid ».
LM-302-b : tout mécanisme futur doit déclarer ses Things via claimedThingIds(), seul
point à étendre — l'oublier le rendrait invisible à la vérification.
Suite complète : 112 tests, 0 échec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pont moteur↔app : déclaration des charges etmvariableload persistée et éditable,
construction des adaptateurs à chaud depuis la config (remplace le registre en dur).
- LoadConfig / LoadConfigNeeds (Q_GADGET typés, introspectables) — forme mot pour mot
du LoadDescriptor §4 (jonction inter-repos avec l'app).
- LoadConfigStore : persistance atomique /var/lib/nymea/energy-load-configuration.json,
validation en bloc, signal changed().
- Handler NymeaEnergy.GetLoadConfig / SetLoadConfig (+ notif LoadConfigChanged). GET typé
objectRef<LoadConfig> ; SET schéma inline o: (powerLevels/needs conditionnels §4).
- EnergyArbitrator : setLoadConfigStore + rebuildEtmVariableLoadAdapters (enabled==true
seulement, §9) ; repli L2 = setPowerSetpoint(0) force=true sur tout etmvariableload
(ferme le trou sécurité ouvert en T2).
- Tests simulation : migration ECS→etmvariableload (arrondi fixed/dynamic, recrédit,
délestage, round-trip powerSetpoint), budget partagé etmvariableload↔PAC, watchdog L2
réactivé, persistance + construction depuis config + injection RPC end-to-end.
PAC SG-Ready reste hors config (§8, gelée). Les 5 tâches du brief sont couvertes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>