diff --git a/energyplugin/etm/config/loadconfigstore.cpp b/energyplugin/etm/config/loadconfigstore.cpp index 75bf672..d2b318f 100644 --- a/energyplugin/etm/config/loadconfigstore.cpp +++ b/energyplugin/etm/config/loadconfigstore.cpp @@ -63,12 +63,54 @@ bool LoadConfigStore::load() qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Entrée ignorée (invalide):" << why; continue; } + // Conflit d'ensemble : on écarte l'entrée fautive (la PREMIÈRE lue garde son Thing) + // plutôt que de rejeter tout le fichier — même tolérance ascendante que ci-dessus. + // Écarter est ici plus sûr que charger : deux adaptateurs sur un organe se + // contrediraient, alors qu'une charge absente ne fait que ne rien commander. + LoadConfigs essai = loaded; + essai.append(c); + QString conflit; + if (!validateSet(essai, &conflit)) { + qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Entrée écartée (conflit d'ensemble):" << conflit; + continue; + } loaded.append(c); } m_configs = loaded; return true; } +bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error) +{ + auto fail = [error](const QString &why) { + if (error) *error = why; + return false; + }; + + QSet ids; + QHash proprietaire; // ThingId normalisé → libellé de la charge + + for (const LoadConfig &c : configs) { + if (ids.contains(c.id())) + return fail(QStringLiteral("l'identifiant de charge \"%1\" apparaît deux fois").arg(c.id())); + ids.insert(c.id()); + + if (!c.enabled()) + continue; // ne construit aucun adaptateur : ne revendique rien + + for (const QString &thing : c.claimedThingIds()) { + const QString deja = proprietaire.value(thing); + if (!deja.isEmpty()) + return fail(QStringLiteral("le Thing %1 est commandé par deux charges actives, " + "\"%2\" et \"%3\" : deux commandes contradictoires et " + "une puissance comptée deux fois") + .arg(thing, deja, c.label())); + proprietaire.insert(thing, c.label()); + } + } + return true; +} + bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error) { // Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté). @@ -79,6 +121,9 @@ bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error) return false; } } + // …puis la cohérence d'ensemble, qu'aucune charge ne peut vérifier seule. + if (!validateSet(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 d1ee7ba..2c33b39 100644 --- a/energyplugin/etm/config/loadconfigstore.h +++ b/energyplugin/etm/config/loadconfigstore.h @@ -45,6 +45,22 @@ public: */ bool setConfigs(const LoadConfigs &configs, QString *error = nullptr); + /*! \brief Cohérence de l'ENSEMBLE, ce qu'aucune charge ne peut vérifier seule. + * + * ECS-110 s'arrête au bord d'une charge : \c LoadConfig::isValid() ne voit pas les + * autres. Deux exigences ne vivent donc que ici — identifiants uniques, et un Thing + * revendiqué par au plus UNE charge active. Le second cas est le même défaut que la + * garde de l'arbitre contre la PAC codée en dur, mais entre deux charges de + * configuration : deux commandes contradictoires sur un organe, et sa puissance + * comptée deux fois dans le budget (règle absolue 1). + * + * \note Seules les charges \c enabled sont confrontées. Une charge désactivée ne + * construit aucun adaptateur et ne commande rien ; l'interdire empêcherait de + * préparer une configuration de remplacement. La bascule \c enabled repasse par + * \c setConfigs(), donc par cette vérification. + */ + static bool validateSet(const LoadConfigs &configs, QString *error = nullptr); + signals: /*! \brief Émis après une persistance réussie de \c setConfigs(). */ void changed(); diff --git a/energyplugin/etm/types/loadconfig.cpp b/energyplugin/etm/types/loadconfig.cpp index 4f3bce1..dc6a653 100644 --- a/energyplugin/etm/types/loadconfig.cpp +++ b/energyplugin/etm/types/loadconfig.cpp @@ -2,6 +2,8 @@ // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "loadconfig.h" + +#include #include // ---- LoadConfigNeeds ------------------------------------------------------- @@ -136,6 +138,33 @@ void LoadConfig::setPowerLevels(const QVariantList &v) m_powerLevels.erase(std::unique(m_powerLevels.begin(), m_powerLevels.end()), m_powerLevels.end()); } +namespace { +//! Forme canonique d'un identifiant de Thing (la config mélange « {uuid} » et « uuid »). +QString normalizedThingId(const QString &raw) +{ + const QUuid u = QUuid::fromString(raw); + return u.isNull() ? raw.trimmed().toLower() : u.toString(QUuid::WithBraces); +} +} + +QSet LoadConfig::claimedThingIds() const +{ + QSet ids; + if (isSgReady()) { + for (const LoadConfigSgReadyState &e : m_sgReady.states) + for (const QString &r : e.relays) + ids.insert(normalizedThingId(r)); + } else if (isRelayRouter()) { + for (const LoadConfigRelay &r : m_relays) + ids.insert(normalizedThingId(r.thingId)); + } else { + // etmvariableload : l'id de la charge EST le ThingId piloté + // (cf. etmvariableloadadapter.cpp, findConfiguredThing(ThingId(m_id))). + ids.insert(normalizedThingId(m_id)); + } + return ids; +} + bool LoadConfig::isValid(QString *error) const { auto fail = [&](const QString &msg) { if (error) *error = msg; return false; }; @@ -180,6 +209,15 @@ bool LoadConfig::isValid(QString *error) const for (const QString &r : e.relays) if (r.isEmpty()) return fail(QStringLiteral("sg-ready : état %1 référence un thingId vide").arg(e.state)); + QSet vusEtat; + for (const QString &r : e.relays) { + // Un même relais dans PLUSIEURS états est normal (état 4 = état 1 + état 3) ; + // deux fois dans le MÊME état ne l'est pas. + if (vusEtat.contains(normalizedThingId(r))) + return fail(QStringLiteral("sg-ready : état %1 déclare deux fois le Thing %2") + .arg(e.state).arg(r)); + vusEtat.insert(normalizedThingId(r)); + } } // ECS-110 / SAFETY.md — l'état 2 (normal, mains off) est le REPLI SÛR du mode dégradé @@ -201,11 +239,18 @@ bool LoadConfig::isValid(QString *error) const return fail(QStringLiteral("relay-router : mode doit être \"fixed\"")); if (m_relays.isEmpty()) return fail(QStringLiteral("relay-router : relays[] requis (non vide)")); + QSet vus; for (const LoadConfigRelay &r : m_relays) { if (r.thingId.isEmpty()) return fail(QStringLiteral("relay-router : un relais a un thingId vide")); if (r.powerW <= 0) return fail(QStringLiteral("relay-router : relais %1 a powerW ≤ 0").arg(r.thingId)); + // ECS-110 — deux étages sur le même Thing : le routeur les fusionnerait + // silencieusement, et la puissance annoncée serait le double de la réelle. + if (vus.contains(normalizedThingId(r.thingId))) + return fail(QStringLiteral("relay-router : le Thing %1 est déclaré sur deux étages") + .arg(r.thingId)); + vus.insert(normalizedThingId(r.thingId)); } if (m_minOnS < 0 || m_minOffS < 0) return fail(QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0")); diff --git a/energyplugin/etm/types/loadconfig.h b/energyplugin/etm/types/loadconfig.h index e6c9003..f9b9733 100644 --- a/energyplugin/etm/types/loadconfig.h +++ b/energyplugin/etm/types/loadconfig.h @@ -8,6 +8,7 @@ #include #include #include +#include /*! * \brief Besoins énergétiques d'une charge pilotée (contrat etmvariableload §4 \c needs). @@ -209,6 +210,18 @@ public: //! \return Vrai si le mécanisme est \c sg-ready (PAC à 4 états normés). bool isSgReady() const { return m_adapter == QStringLiteral("sg-ready"); } + /*! \brief Things que cette charge revendique, forme normalisée. + * + * ECS-110 — un Thing ne peut appartenir qu'à UNE charge active. Deux charges qui + * commandent le même organe se contredisent et le font compter deux fois dans le + * budget (règle absolue 1). La liste dépend du mécanisme : \c etmvariableload pilote + * le Thing qui porte son propre \c id, les deux autres nomment leurs relais. + * + * Les identifiants sont normalisés via \c QUuid : la configuration mélange les formes + * avec et sans accolades, une comparaison textuelle laisserait passer le conflit. + */ + QSet claimedThingIds() const; + //! Vrai si la charge est un routeur de relais (rév. 3 : combinatoire watts→relais côté routeur). //! \return Vrai si \c adapter == "relay-router" — décide de la classe construite au rebuild. bool isRelayRouter() const { return m_adapter == QStringLiteral("relay-router"); } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 672c492..8c32140 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -1476,6 +1476,109 @@ void Simulation::testSgReadyFromConfig() #endif } +void Simulation::testThingOwnershipIsExclusive() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testThingOwnershipIsExclusive nécessite ETM_ARBITRATOR."); +#else + // [ECS-110 étendu] isValid() s'arrête au bord d'une charge. Le conflit ENTRE charges — + // même défaut que la PAC en dur, mais entre deux charges de configuration — ne peut se + // voir qu'au niveau de l'ensemble. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + const QString K1 = "{beaf92e1-0000-4b84-9ce4-e42364863801}"; + const QString K2 = "{bf236e64-0000-4bf1-82a6-4ede03de7502}"; + const QString VAR = "{11111111-0000-4000-8000-000000000003}"; + + auto sgReady = [&](const QString &id, bool enabled, const QStringList &relaisEtat3) { + QVariantList etats; + etats << QVariantMap{{"state", 1}, {"relays", QVariantList()}, {"estimatedPowerW", 0}}; + etats << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0}}; + QVariantList r3; + for (const QString &r : relaisEtat3) r3 << r; + etats << QVariantMap{{"state", 3}, {"relays", r3}, {"estimatedPowerW", 1500}}; + return LoadConfig::fromMap(QVariantMap{ + {"id", id}, {"label", "PAC " + id}, {"adapter", "sg-ready"}, {"mode", "fixed"}, + {"priority", 2}, {"enabled", enabled}, + {"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}}); + }; + auto routeur = [&](const QString &id, bool enabled, const QStringList &relais) { + QVariantList rs; + for (const QString &r : relais) + rs << QVariantMap{{"thingId", r}, {"powerW", 500}}; + return LoadConfig::fromMap(QVariantMap{ + {"id", id}, {"label", "ECS " + id}, {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", enabled}, {"relays", rs}}); + }; + auto variable = [&](const QString &thingId, bool enabled) { + return LoadConfig::fromMap(QVariantMap{ + {"id", thingId}, {"label", "Charge variable"}, {"adapter", "etmvariableload"}, + {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 3}, {"enabled", enabled}}); + }; + + QString err; + LoadConfigs cs; + + // --- Le cas général : K1 appartient au routeur ET à la PAC --------------------------- + cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac", true, {K1}); + QVERIFY2(!LoadConfigStore::validateSet(cs, &err), + "un Thing partagé entre deux charges actives DOIT être refusé"); + QVERIFY2(err.contains("deux charges actives"), err.toUtf8()); + + // --- Même conflit avec etmvariableload, dont l'id EST le ThingId piloté --------------- + cs = LoadConfigs() << routeur("ecs", true, {VAR}) << variable(VAR, true); + QVERIFY2(!LoadConfigStore::validateSet(cs, &err), "conflit relay-router ↔ etmvariableload"); + + // --- Formes d'écriture différentes du même uuid : le conflit reste visible ------------ + QString sansAccolades = K1; sansAccolades.remove('{').remove('}'); + cs = LoadConfigs() << routeur("ecs", true, {K1}) << sgReady("pac", true, {sansAccolades.toUpper()}); + QVERIFY2(!LoadConfigStore::validateSet(cs, &err), + "la normalisation doit voir « {uuid} » et « UUID » comme le même Thing"); + + // --- Identifiants de charge en double : la seconde serait perdue en silence ----------- + cs = LoadConfigs() << routeur("meme-id", true, {K1}) << sgReady("meme-id", true, {K2}); + QVERIFY2(!LoadConfigStore::validateSet(cs, &err), "identifiants dupliqués"); + + // --- Doublons À L'INTÉRIEUR d'une charge ---------------------------------------------- + QVERIFY2(!routeur("ecs", true, {K1, K1}).isValid(&err), "deux étages sur le même Thing"); + QVERIFY2(!sgReady("pac", true, {K2, K2}).isValid(&err), "deux fois le même relais dans un état"); + // Mais un relais présent dans PLUSIEURS états reste légitime (état 4 = état 1 + état 3). + { + QVariantList etats; + etats << QVariantMap{{"state", 1}, {"relays", QVariantList() << K1}, {"estimatedPowerW", 0}}; + etats << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0}}; + etats << QVariantMap{{"state", 4}, {"relays", QVariantList() << K1 << K2}, {"estimatedPowerW", 3000}}; + const LoadConfig pac = LoadConfig::fromMap(QVariantMap{ + {"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, + {"priority", 2}, {"enabled", true}, + {"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}}); + QVERIFY2(pac.isValid(&err), err.toUtf8()); + } + + // --- Une charge DÉSACTIVÉE ne revendique rien : préparer un remplacement reste permis -- + cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac-futur", false, {K1}); + QVERIFY2(LoadConfigStore::validateSet(cs, &err), err.toUtf8()); + // …mais l'activer ensuite repasse par la vérification et échoue. + cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac-futur", true, {K1}); + QVERIFY(!LoadConfigStore::validateSet(cs, &err)); + + // --- Le refus remonte bien par setConfigs (et rien n'est persisté) --------------------- + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-exclusive.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + LoadConfigStore store; + QVERIFY2(store.setConfigs(LoadConfigs() << routeur("ecs", true, {K1, K2}), &err), err.toUtf8()); + cs = LoadConfigs() << routeur("ecs", true, {K1, K2}) << sgReady("pac", true, {K2}); + QVERIFY(!store.setConfigs(cs, &err)); + QCOMPARE(store.configs().count(), 1); // rollback : l'ancienne config tient + + qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); + QFile::remove(cfgPath); +#endif +} + void Simulation::run_data() { // Simulation infos diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index e7a8bc7..cfcaf00 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -112,6 +112,10 @@ private slots: // [LM-300] Union discriminée : le mécanisme sg-ready se configure comme les autres, ses // charges utiles sont mutuellement exclusives, et l'état 2 est obligatoire (ECS-110). void testSgReadyFromConfig(); + // [ECS-110 étendu] Un Thing n'appartient qu'à UNE charge active. Couvre le conflit + // ENTRE charges (relay-router ↔ sg-ready ↔ etmvariableload), les doublons intra-charge, + // les identifiants en double, et la tolérance d'une charge désactivée. + void testThingOwnershipIsExclusive(); void printStates(Thing *thing); void updateChargerMeter(Thing *thing);