diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 36b7bde..d422070 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,48 @@ +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 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 diff --git a/energyplugin/etm/config/loadconfigstore.cpp b/energyplugin/etm/config/loadconfigstore.cpp index d2b318f..c7d1b48 100644 --- a/energyplugin/etm/config/loadconfigstore.cpp +++ b/energyplugin/etm/config/loadconfigstore.cpp @@ -76,6 +76,20 @@ bool LoadConfigStore::load() } loaded.append(c); } + // LM-1209-a — les doublons de rang sont SIGNALÉS, jamais écartés. Le contrôle de bonne + // forme (validateRanks) ne s'applique qu'aux écritures : ici, écarter ferait disparaître + // des charges de la configuration ET de l'arbitrage, sans applySafeState, sur la seule foi + // d'une règle de contrat resserrée après coup. La première écriture cliente répare. + { + QString pourquoi; + if (!validateRanks(loaded, &pourquoi)) + qCWarning(dcNymeaEnergy()) + << "[LoadConfigStore] CONFIGURATION CONSERVÉE MAIS MAL FORMÉE :" << pourquoi + << "— l'ordre de service reste reproductible (tri sur rang PUIS identifiant)," + << "mais il n'est PAS réglable tant que les rangs sont à égalité : il obéit à" + << "des identifiants, donc à personne. À réparer par un SetLoadConfig."; + } + m_configs = loaded; return true; } @@ -111,6 +125,71 @@ bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error) return true; } +bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error) +{ + QHash parRang; + for (const LoadConfig &c : configs) { + const QString deja = parRang.value(c.priority()); + if (!deja.isEmpty()) { + if (error) + *error = QStringLiteral("les charges \"%1\" et \"%2\" portent toutes deux le rang " + "%3 : leur ordre de service serait stable mais NON " + "RÉGLABLE — il se déciderait sur leurs identifiants") + .arg(deja, c.label()).arg(c.priority()); + return false; + } + parRang.insert(c.priority(), c.label()); + } + return true; +} + +bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out, + QString *error) const +{ + QHash stocke; + for (const LoadConfig &c : m_configs) + stocke.insert(c.id(), &c); + + LoadConfigs resultat; + for (LoadConfig c : incoming) { + const LoadConfig *avant = stocke.value(c.id(), nullptr); + const QString marqueAvant = avant ? avant->rankOrigin() : QString(); + + // Règle 3 — un client ne FABRIQUE pas un "auto". La seule tolérance est l'aller-retour + // verbatim d'une entrée qui le porte déjà : sans elle, la neutralité Get→Set du + // contrat serait rompue et tout client renvoyant ce qu'il a lu se verrait refusé. + if (c.rankOrigin() == QLatin1String("auto") + && marqueAvant != QLatin1String("auto")) { + if (error) + *error = QStringLiteral("la charge \"%1\" prétend un rankOrigin \"auto\" que le " + "moteur n'a pas posé : c'est une affirmation sur un geste " + "du moteur, elle ne s'écrit pas depuis un client") + .arg(c.label()); + return false; + } + + if (avant && c.priority() != avant->priority()) { + // Règle 1 — le rang a CHANGÉ : quelqu'un a classé la charge. La marque tombe, et + // elle tombe vers le VIDE, pas vers "user" : le moteur constate un déplacement, il + // ne sait pas si un humain ou un script l'a voulu. Ne rien affirmer est le + // minimum honnête (LM-1209-c). + c.setRankOrigin(QString()); + } else if (avant && c.rankOrigin().isEmpty()) { + // Règle 2 — rang INCHANGÉ et marque omise : on CONSERVE. SetLoadConfig remplace en + // bloc, donc réordonner une AUTRE charge renvoie forcément celle-ci ; l'effacer à + // cette occasion perdrait une marque encore vraie. L'effacement porte sur le RANG, + // jamais sur la réception d'une écriture. + c.setRankOrigin(marqueAvant); + } + // Reste le cas « rang inchangé, marque explicite » : elle est prise telle quelle — + // c'est ainsi qu'un client lève la marque en envoyant "user", sans déplacer la charge. + resultat.append(c); + } + if (out) + *out = resultat; + return true; +} + bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error) { // Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté). @@ -125,6 +204,11 @@ bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error) if (!validateSet(configs, error)) return false; + // …puis la BONNE FORME, qui ne s'applique qu'ici. load() ne l'appelle pas : elle refuse + // une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a). + if (!validateRanks(configs, error)) + return false; + const LoadConfigs previous = m_configs; m_configs = configs; diff --git a/energyplugin/etm/config/loadconfigstore.h b/energyplugin/etm/config/loadconfigstore.h index b425603..af315f0 100644 --- a/energyplugin/etm/config/loadconfigstore.h +++ b/energyplugin/etm/config/loadconfigstore.h @@ -65,6 +65,49 @@ public: */ static bool validateSet(const LoadConfigs &configs, QString *error = nullptr); + /*! + * \brief Contrôle de BONNE FORME, appliqué aux ÉCRITURES et à elles seules. §12 / LM-1209-a. + * + * \param configs Ensemble candidat. \param error Motif du refus, si refus. + * \return Faux si deux charges portent le même rang. + * + * \note **Distincte de \c validateSet(), et la distinction est de sûreté, pas de propreté.** + * \c validateSet() nomme ce qui rend un ensemble DANGEREUX — deux charges sur un même + * organe — et \c load() s'en sert pour ÉCARTER l'entrée fautive. Celle-ci nomme ce qui + * rend un ensemble MAL FORMÉ, et le seul geste correct y est de REFUSER l'écriture + * entière. Les fusionner ferait écarter au démarrage les entrées d'une configuration + * héritée à rangs doublés : des charges disparaîtraient de la configuration ET de + * l'arbitrage, sans \c applySafeState — une règle de contrat convertie en perte de + * configuration silencieuse. + * \note N'est donc JAMAIS appelée par \c load(). Un doublon hérité est **signalé + * bruyamment et CONSERVÉ** ; la première écriture cliente le répare. + */ + static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr); + + /*! + * \brief Réconcilie \c rankOrigin d'une écriture CLIENTE avec l'état persisté. LM-1209-c. + * + * \param incoming Ensemble reçu du client. \param error Motif du refus, si refus. + * \param[out] out Ensemble à persister, \c rankOrigin réconcilié. + * \return Faux si le client tente de FABRIQUER un \c "auto". + * + * Trois règles, et la deuxième est celle qu'on se serait trompé à écrire : + * 1. Rang CHANGÉ pour cet id → la marque est **effacée** : quelqu'un a classé la charge. + * 2. Rang INCHANGÉ → la marque est **conservée telle quelle**, même si le client l'omet. + * `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 — la charge n'a toujours été classée par personne. **L'effacement porte sur le + * RANG, jamais sur la réception d'une écriture.** + * 3. \c "auto" reçu là où le persisté ne l'a pas → **refus**. Un client ne fabrique pas + * une affirmation sur un geste du moteur. L'aller-retour verbatim d'une entrée qui la + * porte déjà reste licite, sans quoi la neutralité Get→Set serait rompue. + * + * \note Un client lève la marque SANS déplacer la charge en envoyant \c "user" — c'est la + * seule voie, et elle est explicite. L'omission ne lève rien (règle 2). + */ + bool reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out, + QString *error = nullptr) const; + signals: /*! \brief Émis après une persistance réussie de \c setConfigs(). */ void changed(); diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index aec8611..66da5be 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -743,15 +743,26 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev) const QString id = ev->thing()->id().toString(); LoadConfigs configs = m_loadConfigStore->configs(); - int rangMin = 0; - bool premier = true; + // LM-1209 — EN QUEUE, jamais en tête. L'auto-provisionnement REMPLIT UN TROU : sans entrée, + // la borne serait arbitrée sans rang réglable. Il ne s'ensuit rien sur sa PLACE — 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. + // + // 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é — ici la liste est vide et max+1 vaut 1. + // + // Queue de LISTE et non queue du 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 (interdit, LM-1209-b). 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 elle place la nouvelle charge derrière tout le monde plutôt que de + // l'intercaler devant une charge classée. + int rangMax = 0; for (const LoadConfig &c : configs) { if (c.id() == id) return; // déjà configurée : on ne touche à rien - if (premier || c.priority() < rangMin) { - rangMin = c.priority(); - premier = false; - } + rangMax = qMax(rangMax, c.priority()); } LoadConfig c; @@ -763,8 +774,14 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev) // « le plugin ne range pas une charge d'office » vise les charges pilotées, dont le domaine // ne se lit nulle part. c.setDomain(QStringLiteral("ev")); - // En tête, sans réécrire le rang de personne. 1 est le premier rang (protocole §5). - c.setPriority(premier ? 1 : qMax(1, rangMin - 1)); + // 1 est le premier rang (protocole §5) : liste vide → 1. Libre par construction, donc + // l'égalité de rang est impossible par ce chemin (LM-1209-a) — et AUCUN autre rang ne + // bouge (LM-1209-b) : insérer est un geste de configuration, pas un geste d'arbitrage. + c.setPriority(qMax(1, rangMax + 1)); + // LM-1209-c — la création se DIT. Le journal ne suffit pas : l'app ne le lit pas, et c'est + // elle qui montre le classement. Cette marque distingue « ce rang est un défaut » de « ce + // rang est un choix » — un client ne peut pas le deviner, rien dans le nombre ne le dit. + c.setRankOrigin(QStringLiteral("auto")); c.setEnabled(true); configs.append(c); @@ -781,8 +798,9 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev) qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << ev->name() << "ajoutée à la configuration des charges (rang" << c.priority() - << ", domaine ev) — entrée créée automatiquement, modifiable par " - "SetLoadConfig."; + << ", domaine ev, rankOrigin=auto) — entrée créée automatiquement, " + "insérée EN QUEUE sans déplacer personne. Le rang est un défaut, " + "pas un choix : modifiable par SetLoadConfig."; } void EnergyArbitrator::syncAdapters() diff --git a/energyplugin/etm/types/loadconfig.cpp b/energyplugin/etm/types/loadconfig.cpp index 124b8bd..c3d826b 100644 --- a/energyplugin/etm/types/loadconfig.cpp +++ b/energyplugin/etm/types/loadconfig.cpp @@ -95,6 +95,14 @@ bool LoadConfigSgReady::hasState(int s) const // ---- LoadConfig ------------------------------------------------------------ +QStringList LoadConfig::knownRankOrigins() +{ + // Énumération FERMÉE, déclarée UNE fois — même règle que knownDomains(). Le VIDE n'en fait + // pas partie et c'est délibéré : il ne vaut pas « autre origine », il vaut « rien n'est + // affirmé ». Le faire entrer dans l'énumération en ferait une troisième affirmation. + return { QStringLiteral("auto"), QStringLiteral("user") }; +} + QStringList LoadConfig::knownDomains() { // Énumération FERMÉE, déclarée UNE fois. isValid() et la documentation en dérivent ; les @@ -208,6 +216,15 @@ bool LoadConfig::isValid(QString *error) const "pour une charge non classée)") .arg(m_domain, knownDomains().join(QStringLiteral(", ")))); + // Origine du rang (LM-1209-c) : même règle d'énumération fermée. Le vide reste licite — il + // n'affirme rien. Ce qu'isValid() ne peut PAS vérifier, c'est QUI écrit "auto" : la + // question n'a de sens que face à l'état PERSISTÉ, et elle est donc tranchée à la + // frontière d'écriture (LoadConfigStore::reconcileRankOrigin). + if (!m_rankOrigin.isEmpty() && !knownRankOrigins().contains(m_rankOrigin)) + return fail(QStringLiteral("rankOrigin \"%1\" hors énumération (attendu : %2, ou absent " + "— l'absence n'affirme rien)") + .arg(m_rankOrigin, knownRankOrigins().join(QStringLiteral(", ")))); + // LM-300 — UNION DISCRIMINÉE par mécanisme. Le discriminant est \c adapter ; chaque // mécanisme a UNE charge utile, et les charges utiles des autres mécanismes DOIVENT être // absentes (LM-302 : les états invalides doivent être inexprimables). Ajouter un @@ -360,6 +377,10 @@ QVariantMap LoadConfig::toMap() const // domaine inconnu plutôt que comme une absence de classement (règle « omis, jamais nul »). if (!m_domain.isEmpty()) m.insert("domain", m_domain); + // Même règle : omis quand vide, jamais une chaîne vide persistée. Un rankOrigin vide dit + // « rien d'affirmé » — le persister ferait de ce silence une valeur. + if (!m_rankOrigin.isEmpty()) + m.insert("rankOrigin", m_rankOrigin); // Mêmes règles que le domaine : omis quand vide, jamais une chaîne vide persistée. if (m_minPowerW > 0) m.insert("minPowerW", m_minPowerW); @@ -395,6 +416,7 @@ LoadConfig LoadConfig::fromMap(const QVariantMap &map) c.m_adapter = map.value("adapter", QStringLiteral("etmvariableload")).toString(); c.m_mode = map.value("mode").toString(); c.m_domain = map.value("domain").toString(); // absent → vide → charge non classée + c.m_rankOrigin = map.value("rankOrigin").toString(); // absent → vide → rien d'affirmé // Aucun contrôle d'EXISTENCE ici, à dessein : LoadConfig est un type pur, sans // ThingManager. Et refuser un ThingId encore inconnu imposerait un ORDRE aux opérations — // déclarer le compteur avant la charge — que rien ne justifie. Un id qui ne résout pas diff --git a/energyplugin/etm/types/loadconfig.h b/energyplugin/etm/types/loadconfig.h index 209fccb..797c6ee 100644 --- a/energyplugin/etm/types/loadconfig.h +++ b/energyplugin/etm/types/loadconfig.h @@ -131,6 +131,8 @@ class LoadConfig Q_PROPERTY(QString mode READ mode WRITE setMode) //! Catégorie d'intention déclarée par l'installateur — énumération FERMÉE, code jamais libellé. Q_PROPERTY(QString domain READ domain WRITE setDomain) + //! Origine du RANG — "auto" | "user" | vide. LM-1209-c. Vide = rien d'affirmé. + Q_PROPERTY(QString rankOrigin READ rankOrigin WRITE setRankOrigin) Q_PROPERTY(QVariantList powerLevels READ powerLevels WRITE setPowerLevels) Q_PROPERTY(int maxPowerW READ maxPowerW WRITE setMaxPowerW) Q_PROPERTY(int priority READ priority WRITE setPriority) @@ -210,6 +212,34 @@ public: QString meterThingId() const { return m_meterThingId; } void setMeterThingId(const QString &v) { m_meterThingId = v; } + /*! + * \brief Origine du RANG de cette charge — \c "auto", \c "user", ou vide. §12 / LM-1209-c. + * + * - \c "auto" : l'entrée a été créée d'office par le moteur et **son rang n'a jamais été + * choisi par personne** — c'est un défaut d'insertion (queue de liste), pas une décision. + * **Seul le moteur écrit cette valeur.** Un client qui la poserait fabriquerait une + * affirmation sur un geste du moteur ; la seule tolérance est l'aller-retour verbatim + * d'une entrée qui la porte déjà. + * - \c "user" : le rang est **assumé**. C'est la façon pour un client de lever la marque + * *sans déplacer la charge* — « j'ai vu ce défaut et il me va ». + * - **vide / absent : rien n'est affirmé.** Ni « choisi », ni « par défaut ». Une + * configuration héritée ne prétend donc pas que ses rangs ont été décidés, et un client + * n'affiche « rang par défaut » que sur un \c "auto" EXPLICITE. + * + * \note **L'absence n'affirme rien** — c'est le même motif de conception que + * \c measurement.source (§11 / LM-1106), où \c "none" AFFIRME « pas mesurable » quand + * l'absence de l'entrée n'affirme rien. Publier le régime plutôt que le déduire d'une + * valeur, et ne jamais laisser un silence passer pour une affirmation. + * \note **L'effacement porte sur le RANG, pas sur l'écriture.** Réordonner d'AUTRES charges + * laisse celle-ci en \c "auto" : elle n'a toujours été classée par personne. Voir + * \c LoadConfigStore::reconcileRankOrigin(). + */ + QString rankOrigin() const { return m_rankOrigin; } + void setRankOrigin(const QString &v) { m_rankOrigin = v; } + + //! \brief Valeurs admises de \c rankOrigin. Énumération FERMÉE, déclarée UNE fois. + static QStringList knownRankOrigins(); + /*! * \brief Sonde du service rendu (température). Vide = aucune. **Lecture seule.** * @@ -357,6 +387,7 @@ private: //! mesure, il ne commande pas (LM-1104), donc deux charges peuvent le partager. uint m_minPowerW = 0; //!< Plancher de modulation (W) ; 0 = aucun (§12). QString m_meterThingId; + QString m_rankOrigin; //! Sonde de température ; vide = aucune. Même règle d'exclusivité que le compteur. QString m_sensorThingId; QList m_powerLevels; //!< Trié croissant, 0 inclus en *fixed* ; vide en *dynamic*. diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 9b20942..4d31057 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -201,6 +201,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana loadItem.insert("o:meterThingId", enumValueName(Uuid)); loadItem.insert("o:sensorThingId", enumValueName(Uuid)); loadItem.insert("priority", enumValueName(Uint)); + // §12 (LM-1209-c) — origine du RANG : "auto" | "user". Absent = rien d'affirmé, ni + // « choisi » ni « par défaut ». "auto" AFFIRME que le moteur a créé l'entrée d'office et + // que personne n'a jamais classé cette charge ; seul le moteur l'écrit, et un client qui + // la poserait fabriquerait une affirmation sur un geste du moteur (refusé). Un client lève + // la marque sans déplacer la charge en envoyant "user" — l'omission, elle, ne lève rien : + // SetLoadConfig remplace en bloc, et réordonner une AUTRE charge renvoie forcément + // celle-ci. Même motif de conception que measurement.source : le régime se publie, et une + // absence n'affirme rien. + loadItem.insert("o:rankOrigin", enumValueName(String)); loadItem.insert("enabled", enumValueName(Bool)); loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); // etmvariableload fixed loadItem.insert("o:maxPowerW", enumValueName(Uint)); // etmvariableload dynamic @@ -642,6 +651,16 @@ JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms) configs.append(LoadConfig::fromMap(item.toMap())); QString why; + // LM-1209-c — la marque de rang se réconcilie ICI, à la frontière CLIENTE, et nulle part + // ailleurs : c'est la seule couche qui sache qu'une écriture vient du dehors. Le moteur, + // lui, pose légitimement "auto" en créant une entrée d'office. + LoadConfigs reconciliees; + if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, &why)) { + qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why; + return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); + } + configs = reconciliees; + if (!m_loadConfigStore->setConfigs(configs, &why)) { qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why; return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 4cd9b1c..04e0928 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -1334,6 +1334,35 @@ d'insertion : une écriture cliente doit être **refusée** si elle pose deux ch > et non réglable** : il obéit à des identifiants, c'est-à-dire à personne. C'est cela que > l'unicité corrige, et c'est une exigence de gouvernabilité, pas de déterminisme. +**LM-1209-a-bis — Une vérification qui ÉCARTE et une vérification qui REFUSE ne peuvent pas +être la même fonction.** *(Règle générale, posée le 2026-08-28 sur un piège trouvé en +implantant LM-1209-a. Elle vaut au-delà des rangs.)* + +Le moteur porte deux familles de contrôles, et leur seul geste correct est **opposé** : + +| Famille | Ce qu'elle nomme | Geste correct | Où | +|---|---|---|---| +| **Sûreté d'ensemble** | l'ensemble est **dangereux** — deux charges commandent le même organe, deux commandes contradictoires | **ÉCARTER** l'entrée fautive et continuer | chargement **et** écriture | +| **Bonne forme** | l'ensemble est **mal formé** — deux charges au même rang | **REFUSER** l'écriture entière | écriture **seulement** | + +**Les fusionner convertit une règle de contrat en perte de configuration silencieuse.** +`LoadConfigStore::load()` applique la vérification d'ensemble en écartant les entrées fautives — +« tolérance ascendante », et c'est le bon geste pour un conflit d'organe : une charge absente ne +commande rien, deux adaptateurs sur un relais se contrediraient. Y ajouter l'unicité de rang +ferait qu'une configuration héritée à rangs doublés **perdrait des charges au démarrage** : +disparues de la configuration *et* de l'arbitrage, **sans `applySafeState`** — donc laissées dans +leur dernier état commandé, exactement le trou d'ECS-413 rouvert par une autre porte. Et sur la +foi d'une règle resserrée *après* que la configuration a été écrite. + +**Le comportement de MIGRATION qui en découle** : au chargement, un doublon hérité est **signalé +bruyamment et CONSERVÉ** ; la première écriture cliente le répare. Rien n'est perdu entre-temps, +et l'installation continue de fonctionner dans l'ordre stable — quoique non réglable — qu'elle +avait déjà. + +> **Le test à écrire est celui de la moitié qui ne se voit pas.** Qu'une écriture mal formée soit +> refusée se remarque tout de suite ; qu'un chargement conserve ce qu'il aurait pu écarter ne se +> remarque jamais — sauf le jour où une box perd trois charges au redémarrage. + **LM-1209-b — Insérer ne renumérote personne.** Aucun autre rang ne bouge. C'est le pendant exact de la suppression côté app, qui laisse les rangs en place : **un geste de configuration ne doit pas devenir un geste d'arbitrage.** Renuméroter pour « faire de la place » modifierait @@ -1348,7 +1377,37 @@ de **« ce rang est un choix »**. C'est la même exigence que C2 de LM-1009 et que LM-1106 : **le régime se publie, il ne se déduit pas.** Un client ne peut pas deviner qu'un rang n'a jamais été choisi — rien dans le nombre ne le -dit. *(Forme du champ : voir la note de contrat, à figer avant écriture.)* +dit. + +**La forme retenue : `o:rankOrigin`, énumération fermée `"auto" | "user"`, absente par défaut.** + +- **`"auto"`** — l'entrée a été créée d'office et **son rang n'a jamais été choisi**. Seul le + moteur l'écrit. +- **`"user"`** — le rang est **assumé**. C'est la voie par laquelle un client lève la marque + **sans déplacer la charge** : « j'ai vu ce défaut et il me va ». +- **absente — rien n'est affirmé.** Ni « choisi », ni « par défaut ». + +> **Une énumération et non un booléen, et la raison est le troisième état.** `rankIsDefault: +> false` *affirmerait* que le rang a été choisi — or c'est précisément 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. C'est le même motif de conception que `measurement.source` (§11), et il mérite +> d'être nommé comme tel plutôt que redécidé au cas par cas : **publier le régime, et ne jamais +> laisser un silence passer pour une affirmation.** + +**Trois règles de transition, et la deuxième est celle qu'on se serait trompé à écrire** : + +1. **Rang CHANGÉ pour cet id → la marque est effacée.** Quelqu'un a classé la charge. Effacée + vers le **vide**, pas vers `"user"` : le moteur constate un déplacement, il ne sait pas si un + humain ou un script l'a voulu — ne rien affirmer est le minimum honnête. +2. **Rang INCHANGÉ → la marque est CONSERVÉE, même si le client l'omet.** `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 — la charge n'a toujours été + classée par personne. **L'effacement porte sur le RANG, jamais sur la réception d'une + écriture.** +3. **`"auto"` reçu là où le persisté ne l'a pas → refus.** Un client ne fabrique pas une + affirmation sur un geste du moteur. L'aller-retour **verbatim** d'une entrée qui la porte déjà + reste licite — sans cette tolérance, la neutralité `Get` → `Set` serait rompue et tout client + renvoyant ce qu'il a lu se verrait refusé. **LM-1207 — Ne PAS ajouter de `priority` à `ChargingInfo`.** Ce serait un **second système de priorité**, et il serait immédiatement insuffisant : il ne saurait pas exprimer diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 1ccc990..8e607e3 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -8,6 +8,11 @@ #include "etm/config/loadconfigstore.h" #include +#include +#include +#include +#include +#include namespace { @@ -385,3 +390,147 @@ void TestLoadModel::testEvChargerClaimsItsThing() QVERIFY2(!LoadConfigStore::validateSet(ensemble, &err), "deux charges actives sur le Thing d'une borne doivent être refusées"); } + +void TestLoadModel::testRankUniquenessRefusesWriteButNeverDiscards() +{ + // [LM-1209-a] Deux vérifications, deux gestes — et les confondre convertirait une règle de + // contrat en PERTE DE CONFIGURATION au démarrage. + // + // validateSet() nomme ce qui rend l'ensemble DANGEREUX (deux charges sur un organe) et + // load() s'en sert pour ÉCARTER l'entrée fautive ; + // validateRanks() nomme ce qui le rend MAL FORMÉ, et le seul geste correct est de + // REFUSER l'écriture entière. + QString err; + LoadConfigs doublon; + doublon.append(LoadConfig::fromMap(borneMap(BORNE, 1))); + QVariantMap autre = borneMap(QStringLiteral("{dddddddd-0000-0000-0000-00000000000f}"), 1); + autre.insert("label", "Seconde borne"); + doublon.append(LoadConfig::fromMap(autre)); + + // Chaque entrée est valide isolément, et l'ensemble n'est PAS dangereux : aucune des deux + // ne commande le Thing de l'autre. C'est pour cela que validateSet() le laisse passer — + // et pour cela qu'il fallait une seconde vérification, pas un durcissement de celle-ci. + for (const LoadConfig &c : doublon) + QVERIFY2(c.isValid(&err), qUtf8Printable(err)); + QVERIFY2(LoadConfigStore::validateSet(doublon, &err), + "un doublon de RANG n'est pas un conflit d'organe : validateSet() ne doit pas le voir"); + + // La bonne forme, elle, refuse — et le motif doit NOMMER ce qui est perdu. Ce n'est pas le + // déterminisme : depuis 3g-2 le tri est total sur (rang, identifiant). C'est que l'ordre + // obéirait à des identifiants, donc à personne. + QVERIFY2(!LoadConfigStore::validateRanks(doublon, &err), + "deux charges au même rang doivent être refusées à l'écriture"); + QVERIFY2(err.contains(QStringLiteral("RÉGLABLE")), qUtf8Printable(err)); + + // …et une écriture entière est rejetée, pas amputée : rien n'est persisté à moitié. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-ranks.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + { + LoadConfigStore store; + QVERIFY2(!store.setConfigs(doublon, &err), "setConfigs doit refuser un doublon de rang"); + QCOMPARE(store.configs().count(), 0); + } + + // ---- Et au CHARGEMENT : conservé, jamais écarté -------------------------------------- + // C'est la moitié qui compte. Une configuration héritée à rangs doublés existe (le banc en + // a porté une) ; l'écarter ferait disparaître des charges de la configuration ET de + // l'arbitrage, sans applySafeState — sur la seule foi d'une règle resserrée après coup. + QFile f(cfgPath); + QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text)); + QVariantList brut; + for (const LoadConfig &c : doublon) + brut.append(c.toMap()); + f.write(QJsonDocument(QJsonObject{{"loads", QJsonArray::fromVariantList(brut)}}).toJson()); + f.close(); + + LoadConfigStore relu; + QCOMPARE(relu.configs().count(), 2); // LES DEUX survivent + QCOMPARE(relu.configs().at(0).priority(), 1); + QCOMPARE(relu.configs().at(1).priority(), 1); // aucun rang n'a été « réparé » d'office + + // Et la première écriture cliente répare, sans que rien n'ait été perdu entre-temps. + LoadConfigs repare = relu.configs(); + repare[1].setPriority(2); + QVERIFY2(relu.setConfigs(repare, &err), qUtf8Printable(err)); + QCOMPARE(relu.configs().count(), 2); +} + +void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite() +{ + // [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est + // qu'on CLASSE la charge — pas qu'on écrive dans le magasin. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rankorigin.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + QString err; + LoadConfigStore store; + + // Deux charges : « posee » classée par un humain au rang 1, « auto » créée d'office en 2. + LoadConfigs init; + QVariantMap posee = borneMap(QStringLiteral("{dddddddd-0000-0000-0000-0000000000a1}"), 1); + posee.insert("label", "Borne posée"); + init.append(LoadConfig::fromMap(posee)); + LoadConfig creee = LoadConfig::fromMap(borneMap(BORNE, 2)); + creee.setRankOrigin(QStringLiteral("auto")); // le MOTEUR l'écrit, lui en a le droit + init.append(creee); + QVERIFY2(store.setConfigs(init, &err), qUtf8Printable(err)); + + // ---- Réordonner une AUTRE charge ne lève PAS la marque --------------------------------- + // C'est le cas qui se serait perdu. SetLoadConfig remplace en bloc : déplacer « posee » + // renvoie forcément l'entrée de la borne créée d'office. Si l'effacement portait sur + // « écriture reçue » plutôt que sur « rang changé », la marque tomberait ici — alors que + // la charge n'a toujours été classée par personne. + LoadConfigs bougeLautre = store.configs(); + bougeLautre[0].setPriority(3); // on déplace « posee », pas la borne + bougeLautre[1].setRankOrigin(QString()); // et le client OMET la marque, comme un + // client naïf qui reconstruit sa charge utile + LoadConfigs reconciliee; + QVERIFY2(store.reconcileRankOrigin(bougeLautre, &reconciliee, &err), qUtf8Printable(err)); + QVERIFY2(store.setConfigs(reconciliee, &err), qUtf8Printable(err)); + QCOMPARE(store.configs().at(1).rankOrigin(), QStringLiteral("auto")); + QCOMPARE(store.configs().at(1).priority(), 2); + + // ---- Déplacer LA charge lève la marque ------------------------------------------------- + LoadConfigs bougeLaBonne = store.configs(); + bougeLaBonne[1].setPriority(9); + QVERIFY2(store.reconcileRankOrigin(bougeLaBonne, &reconciliee, &err), qUtf8Printable(err)); + QVERIFY2(store.setConfigs(reconciliee, &err), qUtf8Printable(err)); + // Vers le VIDE, pas vers "user" : le moteur constate un déplacement, il ne sait pas si un + // humain ou un script l'a voulu. Ne rien affirmer est le minimum honnête. + QVERIFY2(store.configs().at(1).rankOrigin().isEmpty(), + "un rang changé doit lever la marque, sans affirmer autre chose à la place"); + + // ---- Un client ne FABRIQUE pas un "auto" ----------------------------------------------- + LoadConfigs fabrique = store.configs(); + fabrique[1].setRankOrigin(QStringLiteral("auto")); + QVERIFY2(!store.reconcileRankOrigin(fabrique, &reconciliee, &err), + "un client ne doit pas pouvoir affirmer que le moteur a créé ce rang"); + QVERIFY2(err.contains(QStringLiteral("moteur")), qUtf8Printable(err)); + + // ---- …mais l'aller-retour VERBATIM reste licite ---------------------------------------- + // Sans cette tolérance, tout client qui renvoie ce qu'il a lu serait refusé — la + // neutralité Get→Set du contrat serait rompue. + LoadConfigs init2; + LoadConfig c2 = LoadConfig::fromMap(borneMap(BORNE, 2)); + c2.setRankOrigin(QStringLiteral("auto")); + init2.append(c2); + LoadConfigStore store2; + QVERIFY2(store2.setConfigs(init2, &err), qUtf8Printable(err)); + LoadConfigs verbatim = store2.configs(); // relu tel quel, marque comprise + QVERIFY2(store2.reconcileRankOrigin(verbatim, &reconciliee, &err), qUtf8Printable(err)); + QCOMPARE(reconciliee.at(0).rankOrigin(), QStringLiteral("auto")); + + // ---- « user » est la voie EXPLICITE pour lever sans déplacer --------------------------- + LoadConfigs assume = store2.configs(); + assume[0].setRankOrigin(QStringLiteral("user")); + QVERIFY2(store2.reconcileRankOrigin(assume, &reconciliee, &err), qUtf8Printable(err)); + QCOMPARE(reconciliee.at(0).rankOrigin(), QStringLiteral("user")); + + // Et l'énumération est fermée — une valeur libre serait une affirmation qu'on devrait + // supporter le jour où quelqu'un la lit. + LoadConfig hors = LoadConfig::fromMap(borneMap(BORNE, 1)); + hors.setRankOrigin(QStringLiteral("installateur")); + QVERIFY2(!hors.isValid(&err), "rankOrigin hors énumération doit être refusé"); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 752838c..d2a428f 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -66,6 +66,15 @@ private slots: //! Elle revendique son Thing : une borne ne peut pas être aussi une charge pilotée. void testEvChargerClaimsItsThing(); + + // ── §12 / LM-1209 — l'auto-provisionnement remplit un trou, il ne décide pas ─── + //! L'unicité de rang REFUSE une écriture, mais n'écarte JAMAIS au chargement : les deux + //! vérifications sont séparées parce que leurs gestes le sont (LM-1209-a). + void testRankUniquenessRefusesWriteButNeverDiscards(); + + //! La marque de rang s'efface sur un RANG changé, pas sur une écriture reçue — et un + //! client ne peut pas fabriquer un "auto" (LM-1209-c). + void testRankOriginFollowsTheRankNotTheWrite(); }; #endif // TESTLOADMODEL_H diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index a401bb0..a44dea8 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -5782,3 +5782,104 @@ void Simulation::testMeasurementSourceIsPublished() QCOMPARE(qRound(mRepli.value("powerW").toDouble()), 2900); #endif } + +void Simulation::testAutoProvisionedRankGoesToTheQueue() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testAutoProvisionedRankGoesToTheQueue nécessite ETM_ARBITRATOR."); +#else + // [LM-1209] L'auto-provisionnement REMPLIT UN TROU : sans entrée, une borne serait arbitrée + // sans rang réglable. Il ne s'ensuit rien sur sa PLACE. Une borne détectée un mois après la + // mise en service ne doit pas passer devant l'ECS que quelqu'un a réglé exprès — une + // détection ne réordonne pas ce qu'un humain a décidé. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-queue.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); + + // ---- Une installation DÉJÀ classée par un humain --------------------------------------- + QUuid rA = addPowerSwitch(1000, 26661); + QUuid varId = addEtmVariableLoad(27042); + QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(varId)); + + // Posées par la frontière RPC, comme l'app le ferait — c'est le magasin que le handler + // connaît, et c'est lui qui doit porter la marque de rang. + QVariantList l; + l.append(QVariantMap{{"id", "ecs-classe"}, {"label", "ECS classé"}, + {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", true}, {"domain", "ecs"}, + {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, + {"powerW", 1000}}}, + {"minOnS", 0}, {"minOffS", 0}}); + l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"}, + {"adapter", "etmvariableload"}, {"mode", "dynamic"}, + {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}, + {"domain", "ecs"}}); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + // ---- La borne arrive UN MOIS PLUS TARD ------------------------------------------------- + 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 détectée n'est pas entrée en configuration"); + + // EN QUEUE : derrière tout le monde, pas en tête. C'est la correction du lot — le rang + // retombait à 1, à égalité avec l'ECS que quelqu'un avait posé là exprès. + QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3); + + // AUCUN autre rang n'a bougé : insérer est un geste de configuration, pas d'arbitrage. + QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1); + QCOMPARE(cfg.value(varId.toString()).value("priority").toInt(), 2); + + // Et la création SE DIT : le journal ne suffit pas, l'app ne le lit pas et c'est elle qui + // montre le classement. « ce rang est un défaut, pas un choix. » + QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QString("auto")); + // Les charges posées par un humain, elles, n'affirment rien — ni « choisi », ni « défaut ». + QVERIFY2(cfg.value("ecs-classe").value("rankOrigin").toString().isEmpty(), + "une charge posée par un humain ne doit rien affirmer sur l'origine de son rang"); + + // ---- Une SECONDE borne va encore derrière ---------------------------------------------- + // Le rang produit doit rester LIBRE par construction : c'est ce qui rend l'égalité + // impossible par ce chemin, plutôt que de la refuser après coup. + QUuid borne2Id = addCharger("A", 16, 26670); + Thing *borne2 = tm->findConfiguredThing(borne2Id); + QVERIFY(borne2); + armerBorne(borne2); + arb->simulationCallUpdate(t0.addSecs(60)); + QCoreApplication::processEvents(); + + cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")); + QCOMPARE(cfg.value(borne2Id.toString()).value("priority").toInt(), 4); + QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 3); // toujours pas bougé + QCOMPARE(cfg.value("ecs-classe").value("priority").toInt(), 1); + + // Tous distincts : l'unicité est obtenue PAR CONSTRUCTION, pas par un refus tardif. + QSet rangs; + for (auto it = cfg.constBegin(); it != cfg.constEnd(); ++it) + rangs.insert(it.value().value("priority").toInt()); + QCOMPARE(rangs.count(), cfg.count()); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 99d1f63..b9af2f5 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -179,6 +179,12 @@ private slots: // mais un compteur injoignable fait retomber sur l'appareil au lieu de taire la mesure. void testMeasurementSourceIsPublished(); + // ── §12 / LM-1209 ────────────────────────────────────────────────────── + // Une borne détectée après la mise en service entre EN QUEUE : elle ne passe devant + // aucune charge qu'un humain a classée, aucun autre rang ne bouge, le rang produit est + // libre par construction, et l'entrée porte rankOrigin="auto" — « ce rang est un défaut ». + void testAutoProvisionedRankGoesToTheQueue(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing);