fix(config): ECS-110-b — un Thing n'appartient qu'à UNE charge active

Le cas général n'était pas couvert, et le trou était complet. isValid() s'arrête au bord
d'une charge : deux charges de configuration pouvaient revendiquer le même Thing — un
relais déclaré à la fois dans la charge ECS et dans un état SG-Ready — avec exactement
les conséquences que la garde de l'arbitre évite pour la PAC codée en dur : deux
commandes contradictoires sur un organe, sa puissance comptée deux fois dans le budget
(règle absolue 1). Rien ne l'empêchait, rien ne l'aurait signalé.

L'exclusivité est une propriété de l'ENSEMBLE, qu'aucune charge ne peut vérifier seule :
LoadConfigStore::validateSet(), appelée par setConfigs() (rejet total, rien persisté) et
au chargement (l'entrée en conflit est écartée, le reste survit — écarter une charge ne
commande rien, la charger commanderait faux).

Deux doublons intra-charge manquaient aussi : deux étages relay-router sur le même Thing,
que le routeur fusionnait en silence en annonçant le double de la puissance réelle, et un
relais répété dans un même état SG-Ready. Un relais présent dans PLUSIEURS états reste
légitime : l'état 4 est l'union des états 1 et 3.

Portée tranchée : seules les charges enabled sont confrontées — une charge désactivée ne
construit aucun adaptateur, et l'interdire empêcherait de préparer un remplacement ;
activer repasse par setConfigs(). ThingIds normalisés via QUuid, la configuration
mélangeant « {uuid} » et « uuid ».

LM-302-b : tout mécanisme futur doit déclarer ses Things via claimedThingIds(), seul
point à étendre — l'oublier le rendrait invisible à la vérification.

Suite complète : 112 tests, 0 échec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-09 16:29:28 +02:00
parent 0cbc9480db
commit e37f204047
6 changed files with 226 additions and 0 deletions

View File

@ -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<QString> ids;
QHash<QString, QString> 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;

View File

@ -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();

View File

@ -2,6 +2,8 @@
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "loadconfig.h"
#include <QUuid>
#include <algorithm>
// ---- 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<QString> LoadConfig::claimedThingIds() const
{
QSet<QString> 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<QString> 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<QString> 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"));

View File

@ -8,6 +8,7 @@
#include <QVariant>
#include <QVariantList>
#include <QVariantMap>
#include <QSet>
/*!
* \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<QString> 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"); }

View File

@ -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

View File

@ -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);