fix(3g-2): une entrée de configuration supprimée est rattrapée au cycle suivant

LM-1209-e. Le provisionnement 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, rien en 140 s.

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. Elle consommait du
budget sans que personne puisse la classer, et rien ne le disait.

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 ». C'est la leçon qui dépasse
le défaut : 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, qui relit l'état
réel des relais, et l'hypothèse de départ qu'il a remplacée.

L'entrée recréée revient en rankOrigin "auto" — personne ne l'a classée, son rang
est de nouveau un défaut — et le rang posé par l'installateur est PERDU avec
l'entrée. Supprimer n'est donc pas un moyen de retirer une borne de l'arbitrage :
elle revient, déclassée, en queue et avec son badge. C'est exactement pourquoi la
marque doit se voir. enabled: false reste la voie et conserve le rang.

Le refus de provisionnement n'est annoncé qu'une fois par borne
(m_provisionRefuse) : le rattrapage étant cyclique, le répéter produirait un
avertissement par minute et pour toujours — la règle est déjà posée pour les
charges sans décision dans buildTelemetry(). Le drapeau tombe au premier succès.

testDeletedEvChargerConfigComesBack couvre les deux moitiés : le rattrapage, et
le cas négatif — il ne se rejoue PAS quand l'entrée existe, sans quoi il
réécrirait la configuration à chaque cycle, donc un changed(), donc un rebuild
d'adaptateurs, donc des verrous réarmés en boucle (ECS-412). Vérifié échouant
avec l'ancien comportement.

Le brief §6-C, qui promettait à l'app l'inverse de ce qui se passait sur le seul
geste client qui détruit de la configuration, est corrigé — et dit désormais ce
que la suppression coûte vraiment : le rang.

Passe aussi LM-1209-d (grouper à l'affichage, jamais à l'écriture) et
l'avertissement de sonde en tête d'INTERFACE.md.

amd64 0/0. simulation 32/32, 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
This commit is contained in:
Patrick Schurig 2026-08-29 11:08:08 +02:00
parent 87c97e407f
commit 2c46f8e798
7 changed files with 218 additions and 28 deletions

View File

@ -236,9 +236,12 @@ intactes et rangs inchangés ; `rankOrigin: "auto"` fabriqué par un client **re
> absente de la configuration » — rouvert par le côté client. Il ne se referme qu'au
> **redémarrage de `nymead`**.
>
> **Correctif proposé, NON écrit** : reprovisionner toute borne sans entrée de configuration,
> pas seulement à la naissance de l'adaptateur. Petit, mais c'est un changement de
> comportement — à trancher.
> **CORRIGÉ en `1.15.2+etm28`** (LM-1209-e) : le rattrapage est cyclique, sa condition est
> « cette borne n'a pas d'entrée » et non « l'adaptateur vient de naître ». Une entrée
> supprimée revient au cycle suivant, **en queue et en `rankOrigin: "auto"`** — le rang posé
> par l'installateur est perdu avec l'entrée, ce qui fait de la suppression un geste
> déclassant, à ne pas confondre avec `enabled: false`. Le REFUS de provisionnement, lui, ne
> s'annonce qu'une fois par borne : cyclique, il noierait sinon le journal.
**DÉPLOYÉ SUR `.75` — `1.15.2+etm26`, 2026-08-28.** Cross-build arm64 dans `build-cross-arm64`
(bundle git → `/root/build-etm26`), posé par `deploy.sh`, version relue sur la box, `nymead`

View File

@ -257,15 +257,21 @@ C'est exactement le « geste, pas une suppression d'office » que vous demandez,
> cycle. L'adaptateur est construit depuis le **Thing** — il survit à la suppression de
> l'entrée, donc la borne n'est plus jamais proposée à la configuration.
>
> **Ce que cela produit** : la borne reste **arbitrée** mais **absente de `GetLoadConfig`**, à un
> rang de queue non réglable. C'est exactement le trou que 3g-2 avait refermé — « arbitrée mais
> absente de la configuration » — rouvert par le côté client. Il ne se referme qu'au
> **redémarrage de `nymead`**.
> **Ce que cela produisait** : la borne restait **arbitrée** mais **absente de
> `GetLoadConfig`**, à un rang de queue non réglable — le trou que 3g-2 avait refermé, rouvert
> par le côté client.
>
> **Donc : ne supprimez pas l'entrée d'une borne dont le Thing vit.** `enabled: false` reste la
> bonne voie pour la sortir de l'arbitrage — ce point-là ne change pas. Un correctif moteur est
> proposé (reprovisionner toute borne sans entrée, pas seulement à la naissance de
> l'adaptateur) ; il n'est pas écrit, la décision appartient à Patrick.
> **CORRIGÉ en `1.15.2+etm28`** (`LM-1209-e`). Le rattrapage est désormais **cyclique** : la
> condition est « cette borne n'a pas d'entrée », plus « l'adaptateur vient de naître ». Une
> entrée supprimée est donc **recréée au cycle suivant**.
>
> **Mais elle revient DÉCLASSÉE — et c'est ce qu'il faut montrer à l'installateur.** Elle
> réapparaît **en queue**, avec `rankOrigin: "auto"` : personne ne l'a classée, son rang est de
> nouveau un défaut. **Le rang qu'il avait posé est perdu avec l'entrée.**
>
> **Donc la suppression d'une entrée n'est PAS un moyen de retirer une borne de l'arbitrage** —
> elle revient, à une autre place. `enabled: false` est la voie, et lui conserve son rang. Si
> votre écran propose une suppression, il devrait le dire avant, pas après.
### D — instruction reçue, rien ne bouge côté moteur

View File

@ -790,11 +790,22 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
// 7-c — l'échec ne doit pas être plus discret que le succès : sans entrée, la borne
// reste arbitrée mais absente de GetLoadConfig, et c'est précisément le trou qu'on
// vient refermer.
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] Configuration automatique de la borne"
<< ev->name() << "REFUSÉE :" << erreur
<< "— elle sera arbitrée sans rang configurable.";
//
// …mais UNE SEULE FOIS par borne. Depuis LM-1209-e le rattrapage est tenté à chaque
// cycle : répéter l'avertissement en produirait un par minute et pour toujours, ce qui
// noierait le vrai signal — c'est la règle déjà posée pour les bornes sans décision
// dans buildTelemetry(). Le drapeau est levé au premier succès, donc un défaut qui se
// résout se redit s'il revient.
if (!m_provisionRefuse.contains(id)) {
m_provisionRefuse.insert(id);
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] Configuration automatique de la borne"
<< ev->name() << "REFUSÉE :" << erreur
<< "— elle sera arbitrée sans rang configurable. Réessayé"
<< "à chaque cycle ; cet avertissement ne sera pas répété.";
}
return;
}
m_provisionRefuse.remove(id);
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << ev->name()
<< "ajoutée à la configuration des charges (rang" << c.priority()
@ -805,14 +816,25 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
void EnergyArbitrator::syncAdapters()
{
// Crée les adapters manquants
// Crée les adapters manquants, et RATTRAPE toute borne sans entrée de configuration.
//
// LM-1209-e — les deux gestes sont SÉPARÉS, et ils l'étaient à tort. Le provisionnement
// vivait dans la branche `!m_adapters.contains(id)`, donc il ne tournait qu'UNE FOIS par
// vie d'adaptateur. 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, et la borne restait ARBITRÉE mais ABSENTE de GetLoadConfig, à un rang de
// queue non réglable (evPriority() → 1000) — le trou refermé en 3g-2, rouvert par le côté
// client. Mesuré sur `.75` le 2026-08-29 : rien recréé en 140 s.
//
// Le rattrapage est donc tenté à CHAQUE cycle. provisionEvChargerConfig() rend la main
// aussitôt si l'entrée existe : la condition n'est pas « l'adaptateur vient de naître »,
// c'est « cette borne n'a pas d'entrée ».
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
const QString id = it.key().toString();
if (!m_adapters.contains(id)) {
if (!m_adapters.contains(id))
m_adapters[id] = new EvAdapter(it.value(), this);
// 3g-2 — la borne entre AUSSI dans la configuration, pour y porter son rang.
provisionEvChargerConfig(it.value());
}
// 3g-2 — la borne entre AUSSI dans la configuration, pour y porter son rang.
provisionEvChargerConfig(it.value());
}
// Supprime les adapters obsolètes
for (const QString &id : m_adapters.keys()) {

View File

@ -434,19 +434,32 @@ private:
* Le rang, le libellé, le domaine \c ev, \c enabled. Aucune charge utile : les limites de
* la borne viennent du Thing (voir \c LoadConfig::isEvCharger()).
*
* \par Rang par défaut
* En **tête**, sans bousculer les charges déjà classées : le plus petit rang existant
* moins un, borné à 1. Ce n'est pas une politique du plugin — l'ordre des domaines est un
* choix de mise en service, porté par l'app ; c'est seulement un point de départ qui ne
* réécrit le rang de personne.
* \par Rang par défaut — EN QUEUE (\rule{LM-1209})
* `1 + max(rangs existants)`, donc libre par construction et sans bousculer personne. Ce
* n'est pas une politique du plugin : le rang est un choix de l'installateur, et une
* détection ne réordonne pas ce qu'un humain a décidé. Une borne détectée un mois après la
* mise en service passerait sinon devant l'ECS que quelqu'un a réglé exprès.
*
* \note **Journalisée, une ligne par borne, à la création seulement.** Une entrée créée
* d'office est une écriture que personne n'a demandée : sans trace, un installateur
* verrait apparaître des charges dans son écran sans savoir d'où elles viennent.
* \par Appelée à CHAQUE cycle, pas seulement à la naissance de l'adaptateur
* \rule{LM-1209-e}. Rend la main aussitôt si l'entrée existe : la condition est « cette
* borne n'a pas d'entrée », jamais « l'adaptateur vient de naître ». C'est ce qui **rattrape
* une suppression depuis l'app** — l'adaptateur, construit depuis le Thing, survit à la
* disparition de l'entrée, et sans ce rattrapage la borne restait arbitrée mais absente de
* \c GetLoadConfig, à un rang non réglable.
*
* \note **Une entrée recréée revient en \c rankOrigin "auto"**, et c'est voulu : personne
* ne l'a classée, son rang est de nouveau un défaut. L'installateur qui vient de la
* supprimer la voit donc réapparaître **en queue, avec son badge** — ce qui se dit, au
* journal et dans la charge utile, plutôt que de se subir.
* \note **Journalisée, une ligne par borne à la création.** Une entrée créée d'office est
* une écriture que personne n'a demandée : sans trace, un installateur verrait apparaître
* des charges dans son écran sans savoir d'où elles viennent. Un REFUS, lui, n'est
* annoncé qu'une fois par borne (\c m_provisionRefuse) : le rattrapage étant cyclique,
* le répéter produirait un avertissement par minute et noierait le vrai signal.
* \note L'entrée n'est JAMAIS supprimée d'office quand le Thing disparaît — c'est une
* configuration, et l'installateur a pu la classer. Elle se lit alors comme une charge
* dont l'appareil manque (\c LOAD_UNAVAILABLE, \c faultCode \c THING_MISSING).
* \param ev Borne fraîchement détectée.
* \param ev Borne détectée, ou borne connue dont l'entrée manque.
*/
void provisionEvChargerConfig(EvCharger *ev);
@ -557,6 +570,10 @@ private:
* consigne, compteur muet, ce que SAFETY.md interdit.
*/
QSet<QString> m_waterfallEvIds;
//! Bornes dont la configuration automatique a été REFUSÉE — pour n'avertir qu'une fois.
//! Depuis LM-1209-e le rattrapage est tenté à chaque cycle ; répéter l'avertissement en
//! produirait un par minute et noierait le vrai signal. Vidée au premier succès.
QSet<QString> m_provisionRefuse;
//! loadId → charge ARBITRÉE, tous mécanismes confondus (relay-router | etmvariableload |
//! sg-ready). Table UNIQUE, et c'est un invariant de contrat : toutes ses entrées viennent
//! de \c LoadConfigStore, donc l'ensemble publié par \c NymeaEnergy.GetLoadTelemetry est

View File

@ -1369,6 +1369,40 @@ doit pas devenir un geste d'arbitrage.** Renuméroter pour « faire de la place
l'ordre de service de charges auxquelles personne n'a touché — et le ferait sans qu'aucune
décision d'exploitation ait été prise.
**LM-1209-e — Le rattrapage est CYCLIQUE : la condition est « cette charge n'a pas d'entrée »,
jamais « l'adaptateur vient de naître ».** *(Défaut trouvé sur `.75` le 2026-08-29, corrigé le
même jour.)*
Le provisionnement vivait dans la branche « l'adaptateur vient d'être créé ». 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é, rien en 140 s.
**Ce que cela produisait est le trou de 3g-2, rouvert par le côté client** : la charge restait
**arbitrée** mais **absente de `GetLoadConfig`**, à un rang de queue non réglable. Elle
consommait du budget sans que personne puisse la classer, et rien ne le disait. Le seul
rattrapage était un redémarrage du service.
> **La leçon dépasse ce défaut** : un rattrapage dont la condition est un **événement**
> (« à la naissance de… ») ne rattrape rien, puisqu'il ne se rejoue pas. Une condition d'**état**
> (« cette charge n'a pas d'entrée ») se réévalue, donc elle répare. C'est la 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 que l'installateur avait posé est **perdu avec
l'entrée** — c'est le prix de la suppression, et c'est précisément pourquoi la marque doit se
voir : la charge réapparaît **en queue, avec son badge**, plutôt que de revenir en silence à une
place que personne n'a choisie.
> ⚠️ **Corollaire d'exploitation** : supprimer l'entrée d'une charge dont le Thing vit n'est
> **pas** un moyen de la retirer de l'arbitrage — elle revient au cycle suivant, déclassée.
> `enabled: false` est la voie, et lui conserve son rang.
>
> **Et le REFUS de provisionnement ne s'annonce qu'une fois par charge.** Le rattrapage étant
> cyclique, répéter l'avertissement en produirait un par minute et pour toujours — la règle est
> déjà posée pour les charges sans décision (`buildTelemetry()`) : avertir à chaque cycle noie
> le vrai signal. Le drapeau tombe au premier succès, donc 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.** *(Point
ouvert soulevé par l'agent app, tranché le 2026-08-29.)*

View File

@ -5883,3 +5883,105 @@ void Simulation::testAutoProvisionedRankGoesToTheQueue()
QCOMPARE(rangs.count(), cfg.count());
#endif
}
void Simulation::testDeletedEvChargerConfigComesBack()
{
#ifndef ETM_ARBITRATOR
QSKIP("testDeletedEvChargerConfigComesBack nécessite ETM_ARBITRATOR.");
#else
// [LM-1209-e · mesuré sur `.75` le 2026-08-29] Le provisionnement vivait dans la branche
// « l'adaptateur vient de naître ». 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 — et la borne restait arbitrée, absente de GetLoadConfig, à
// un rang de queue non réglable. Le trou refermé en 3g-2, rouvert par le côté client.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-reprov.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
meter->setStateValue("currentPower", -4000);
QUuid borneId = addCharger("A", 16, 26657);
Thing *borne = tm->findConfiguredThing(borneId);
QVERIFY(borne);
armerBorne(borne);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
QHash<QString, QVariantMap> cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfg.contains(borneId.toString()), "la borne n'est pas entrée en configuration");
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
const int rangInitial = cfg.value(borneId.toString()).value("priority").toInt();
// ---- L'installateur classe la borne : la marque tombe -----------------------------------
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
for (int i = 0; i < l.count(); ++i) {
QVariantMap m = l.at(i).toMap();
if (m.value("id").toString() == borneId.toString()) m.insert("priority", rangInitial + 3);
l[i] = m;
}
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfg.value(borneId.toString()).value("rankOrigin").toString().isEmpty(),
"un rang posé par un humain doit lever la marque");
// ---- …puis il SUPPRIME l'entrée, le Thing restant vivant --------------------------------
QVariantList sans;
for (const QVariant &v : injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList())
if (v.toMap().value("id").toString() != borneId.toString())
sans.append(v);
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", sans}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
QVERIFY2(!configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()),
"l'entrée devait être retirée par l'écriture cliente");
// ---- Le cycle suivant la RATTRAPE -------------------------------------------------------
// Sans LM-1209-e, rien ne se passe : l'adaptateur existe déjà, donc le provisionnement
// n'est plus jamais tenté, et la borne reste arbitrée hors configuration.
arb->simulationCallUpdate(t0.addSecs(60));
QCoreApplication::processEvents();
cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"));
QVERIFY2(cfg.contains(borneId.toString()),
"une borne dont le Thing vit doit retrouver une entrée de configuration");
// Et elle revient EN QUEUE et en « auto » : personne ne l'a classée, son rang est de
// nouveau un défaut. Le rang que l'installateur avait posé est perdu avec l'entrée — c'est
// le prix de la suppression, et c'est pourquoi la marque doit se voir.
QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto"));
int rangMax = 0;
for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it)
if (it.key() != borneId.toString())
rangMax = qMax(rangMax, it.value().value("priority").toInt());
QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), qMax(1, rangMax + 1));
// ---- Et le rattrapage ne se REJOUE pas quand l'entrée existe ----------------------------
// Sans quoi il réécrirait la configuration à chaque cycle — donc un changed(), donc un
// rebuild d'adaptateurs, donc des verrous réarmés en boucle (ECS-412).
const QVariantMap avant = cfg.value(borneId.toString());
arb->simulationCallUpdate(t0.addSecs(120));
QCoreApplication::processEvents();
const QVariantMap apres = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig"))
.value(borneId.toString());
QCOMPARE(apres, avant);
#endif
}

View File

@ -185,6 +185,12 @@ private slots:
// libre par construction, et l'entrée porte rankOrigin="auto" — « ce rang est un défaut ».
void testAutoProvisionedRankGoesToTheQueue();
// [LM-1209-e] Supprimer l'entrée d'une borne dont le Thing VIT la fait RECRÉER au cycle
// suivant — en queue, et de nouveau en rankOrigin "auto" : personne ne l'a classée. Sans
// ce rattrapage, la borne reste arbitrée mais absente de GetLoadConfig, à un rang non
// réglable — le trou de 3g-2 rouvert par le côté client.
void testDeletedEvChargerConfigComesBack();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);