diff --git a/AGENTS.md b/AGENTS.md index 1a004f3..085a4d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/docs/BRIEF_depuis_plugin.md b/docs/BRIEF_depuis_plugin.md index 6d759f2..cfb296d 100644 --- a/docs/BRIEF_depuis_plugin.md +++ b/docs/BRIEF_depuis_plugin.md @@ -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 diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 66da5be..a20b53d 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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()) { diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index d929f2f..af2f942 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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 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 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 diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index e10201d..225b8e2 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -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.)* diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index c696adc..3450cf3 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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 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 +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index b9af2f5..23525b7 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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);