feat(etm): LoadConfig rév. 3 — forme relay-router (relays[]) + validation conditionnelle

LoadConfig gagne la forme "relay-router" (ECS multipalier rév. 3) : liste de relais
power (relays[] = [{thingId, powerW}]) + minOnS/minOffS. Coexiste avec la forme
"etmvariableload" (continu/dynamic ou multipalier natif). powerLevels NON stockés pour
le cas relais (DÉRIVÉS par le routeur en étape 3-4).

- LoadConfigRelay (struct) + relays/minOnS/minOffS dans LoadConfig (Q_GADGET + toMap/fromMap).
- isValid() CONDITIONNELLE au type (adapter/mode), pas "tous champs requis" :
  relay-router → relays[] valides ; etmvariableload → powerLevels (fixed) | maxPowerW (dynamic).
- Schéma SET : TOUS les champs spécifiques en "o:" (powerLevels, maxPowerW, relays, minOnS,
  minOffS, needs) — sinon nymea rejette une des deux formes avant le handler (bug objectRef
  strict de T4, doublé en rév. 3). GET reste typé objectRef<LoadConfig>.
- testLoadConfigRpc étendu : SET des DEUX formes (etmvariableload + relay-router) tous deux
  acceptés, round-trip relays[]/minOnS, rejets conditionnels (powerLevels sans 0 ; relays[] vide).

Build prod 0/0 ; suite config/L2/migrés verte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-28 11:49:52 +02:00
parent 1f976f0189
commit 5585a5c483
4 changed files with 167 additions and 32 deletions

View File

@ -24,8 +24,41 @@ LoadConfigNeeds LoadConfigNeeds::fromMap(const QVariantMap &map)
return n;
}
// ---- LoadConfigRelay -------------------------------------------------------
QVariantMap LoadConfigRelay::toMap() const
{
QVariantMap m;
m.insert("thingId", thingId);
m.insert("powerW", powerW);
return m;
}
LoadConfigRelay LoadConfigRelay::fromMap(const QVariantMap &map)
{
LoadConfigRelay r;
r.thingId = map.value("thingId").toString();
r.powerW = map.value("powerW").toInt();
return r;
}
// ---- LoadConfig ------------------------------------------------------------
QVariantList LoadConfig::relays() const
{
QVariantList list;
for (const LoadConfigRelay &r : m_relays)
list.append(r.toMap());
return list;
}
void LoadConfig::setRelays(const QVariantList &v)
{
m_relays.clear();
for (const QVariant &item : v)
m_relays.append(LoadConfigRelay::fromMap(item.toMap()));
}
QVariantList LoadConfig::powerLevels() const
{
QVariantList list;
@ -48,23 +81,47 @@ bool LoadConfig::isValid(QString *error) const
{
auto fail = [&](const QString &msg) { if (error) *error = msg; return false; };
if (m_adapter != QStringLiteral("etmvariableload"))
return fail(QStringLiteral("adapter doit être \"etmvariableload\" (reçu: %1)").arg(m_adapter));
// Validation CONDITIONNELLE au type (contrat rév. 3) — pas "tous champs requis".
// Les deux formes (relay-router / etmvariableload) sont mutuellement exclusives ; le schéma
// JSON-RPC SET marque TOUS les champs spécifiques en optionnel, c'est ICI qu'on tranche.
if (m_id.isEmpty())
return fail(QStringLiteral("id de charge vide"));
if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic"))
return fail(QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode));
if (m_mode == QStringLiteral("fixed")) {
if (m_powerLevels.isEmpty())
return fail(QStringLiteral("mode fixed : powerLevels requis (non vide)"));
if (m_powerLevels.first() != 0)
return fail(QStringLiteral("mode fixed : powerLevels doit inclure 0"));
} else { // dynamic
if (m_maxPowerW <= 0)
return fail(QStringLiteral("mode dynamic : maxPowerW requis (> 0)"));
if (!m_powerLevels.isEmpty())
return fail(QStringLiteral("mode dynamic : powerLevels doit être absent"));
if (m_adapter == QStringLiteral("relay-router")) {
// Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici).
if (m_mode != QStringLiteral("fixed"))
return fail(QStringLiteral("relay-router : mode doit être \"fixed\""));
if (m_relays.isEmpty())
return fail(QStringLiteral("relay-router : relays[] requis (non vide)"));
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));
}
if (m_minOnS < 0 || m_minOffS < 0)
return fail(QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0"));
} else if (m_adapter == QStringLiteral("etmvariableload")) {
// Cas thing unique : continu (dynamic) ou multipalier natif (fixed + powerLevels, legacy).
if (!m_relays.isEmpty())
return fail(QStringLiteral("etmvariableload : relays[] interdit (réservé à relay-router)"));
if (m_mode == QStringLiteral("fixed")) {
if (m_powerLevels.isEmpty())
return fail(QStringLiteral("etmvariableload fixed : powerLevels requis (non vide)"));
if (m_powerLevels.first() != 0)
return fail(QStringLiteral("etmvariableload fixed : powerLevels doit inclure 0"));
} else { // dynamic
if (m_maxPowerW <= 0)
return fail(QStringLiteral("etmvariableload dynamic : maxPowerW requis (> 0)"));
if (!m_powerLevels.isEmpty())
return fail(QStringLiteral("etmvariableload dynamic : powerLevels doit être absent"));
}
} else {
return fail(QStringLiteral("adapter inconnu \"%1\" (attendu \"relay-router\" ou \"etmvariableload\")").arg(m_adapter));
}
return true;
}
@ -84,6 +141,12 @@ QVariantMap LoadConfig::toMap() const
const QVariantMap needs = m_needs.toMap();
if (!needs.isEmpty())
m.insert("needs", needs);
// rév. 3 — relais (cas relay-router) : sérialisés seulement s'ils existent.
if (!m_relays.isEmpty()) {
m.insert("relays", relays());
m.insert("minOnS", m_minOnS);
m.insert("minOffS", m_minOffS);
}
return m;
}
@ -99,6 +162,9 @@ LoadConfig LoadConfig::fromMap(const QVariantMap &map)
c.m_priority = map.value("priority").toInt();
c.m_enabled = map.value("enabled", true).toBool();
c.m_needs = LoadConfigNeeds::fromMap(map.value("needs").toMap());
c.setRelays(map.value("relays").toList());
c.m_minOnS = map.value("minOnS").toInt();
c.m_minOffS = map.value("minOffS").toInt();
return c;
}

View File

@ -36,6 +36,20 @@ private:
};
Q_DECLARE_METATYPE(LoadConfigNeeds)
/*!
* \brief Un relais d'une charge \c relay-router (contrat rév. 3 §4) : un thing \c power + sa
* puissance nominale (W). Imbriqué dans \c LoadConfig.relays vit côté ROUTEUR, ne franchit
* JAMAIS la frontière vers l'optimiseur (qui ne voit que des watts dérivés).
*/
struct LoadConfigRelay
{
QString thingId; //!< ThingId du thing implémentant l'interface \c power.
int powerW = 0; //!< Puissance nominale de ce relais (W).
QVariantMap toMap() const;
static LoadConfigRelay fromMap(const QVariantMap &map);
};
/*!
* \brief Déclaration de config d'une charge pilotée contrat etmvariableload §4.
*
@ -59,6 +73,10 @@ class LoadConfig
Q_PROPERTY(int priority READ priority WRITE setPriority)
Q_PROPERTY(bool enabled READ enabled WRITE setEnabled)
Q_PROPERTY(LoadConfigNeeds needs READ needs WRITE setNeeds)
// rév. 3 — cas relay-router (la combinatoire vit côté routeur, jamais chez l'optimiseur) :
Q_PROPERTY(QVariantList relays READ relays WRITE setRelays) //!< [{thingId, powerW}]
Q_PROPERTY(int minOnS READ minOnS WRITE setMinOnS)
Q_PROPERTY(int minOffS READ minOffS WRITE setMinOffS)
public:
LoadConfig() {}
@ -86,8 +104,20 @@ public:
LoadConfigNeeds needs() const { return m_needs; }
void setNeeds(const LoadConfigNeeds &v) { m_needs = v; }
//! Vrai si *dynamic* (powerLevels vide). Sinon *fixed*.
bool isDynamic() const { return m_powerLevels.isEmpty(); }
//! relays en QVariantList (introspection / sérialisation) ; typé via relaysList().
QVariantList relays() const;
void setRelays(const QVariantList &v);
QList<LoadConfigRelay> relaysList() const { return m_relays; }
int minOnS() const { return m_minOnS; }
void setMinOnS(int v) { m_minOnS = v; }
int minOffS() const { return m_minOffS; }
void setMinOffS(int v) { m_minOffS = v; }
//! Vrai si la charge est un routeur de relais (rév. 3 : combinatoire watts→relais côté routeur).
bool isRelayRouter() const { return m_adapter == QStringLiteral("relay-router"); }
//! Vrai si *dynamic* (modulation continue). Sinon *fixed*.
bool isDynamic() const { return m_mode == QStringLiteral("dynamic"); }
/*!
* \brief Valide la cohérence (contrat §4/§6) : adapter, id, mode, paliers/plafond.
@ -109,6 +139,9 @@ private:
int m_priority = 0;
bool m_enabled = true;
LoadConfigNeeds m_needs;
QList<LoadConfigRelay> m_relays; //!< rév. 3 — relais du routeur (cas relay-router).
int m_minOnS = 0;
int m_minOffS = 0;
};
Q_DECLARE_METATYPE(LoadConfig)

View File

@ -153,23 +153,31 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
params.clear(); returns.clear();
description = "Replace the full set of controllable load configs. Rejected as a whole "
"(energyError) if any entry is invalid; persisted and applied atomically.";
// Schéma inline (pas objectRef<LoadConfig>) : le contrat §4 rend powerLevels conditionnel
// (absent si dynamic) et needs optionnel — objectRef rendrait TOUS les champs requis et
// rejetterait l'entrée §4 de l'app. Les clés "o:" marquent l'optionnel. Le GET reste typé
// objectRef<LoadConfig> (pack émet toujours tous les champs).
// Schéma inline (pas objectRef<LoadConfig>) : le contrat rév. 3 a DEUX formes exclusives
// (relay-router : relays[]+minOnS/minOffS — etmvariableload : powerLevels|maxPowerW).
// objectRef rendrait TOUS les champs requis → nymea rejetterait une des deux formes AVANT
// le handler. On marque donc TOUS les champs spécifiques "o:" ; la validation conditionnelle
// (par adapter/mode) vit dans LoadConfig::isValid(). Seuls id/label/adapter/mode/priority/
// enabled sont requis (présents dans les deux formes). GET reste typé objectRef<LoadConfig>.
QVariantMap needsItem;
needsItem.insert("o:dailyDeadline", enumValueName(String));
needsItem.insert("o:minEnergyWhPerDay", enumValueName(Uint));
QVariantMap relayItem;
relayItem.insert("thingId", enumValueName(String));
relayItem.insert("powerW", enumValueName(Uint));
QVariantMap loadItem;
loadItem.insert("id", enumValueName(String));
loadItem.insert("label", enumValueName(String));
loadItem.insert("adapter", enumValueName(String));
loadItem.insert("mode", enumValueName(String));
loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint));
loadItem.insert("maxPowerW", enumValueName(Uint));
loadItem.insert("priority", enumValueName(Uint));
loadItem.insert("enabled", enumValueName(Bool));
loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); // etmvariableload fixed
loadItem.insert("o:maxPowerW", enumValueName(Uint)); // etmvariableload dynamic
loadItem.insert("o:needs", needsItem);
loadItem.insert("o:relays", QVariantList() << relayItem); // relay-router (rév. 3)
loadItem.insert("o:minOnS", enumValueName(Uint));
loadItem.insert("o:minOffS", enumValueName(Uint));
params.insert("loadConfigs", QVariantList() << loadItem);
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeControlThings);

View File

@ -381,7 +381,9 @@ void Simulation::testLoadConfigRpc()
QVariant resp = injectAndWait("NymeaEnergy.GetLoadConfig");
QVERIFY(resp.toMap().value("params").toMap().value("loadConfigs").toList().isEmpty());
// 2. SetLoadConfig : une fixed (thing réel) + une dynamic.
// 2. SetLoadConfig : les DEUX formes rév. 3 (mutuellement exclusives) doivent être acceptées
// par le MÊME schéma SET — sinon nymea rejette une forme avant le handler (bug objectRef
// strict de T4, doublé). Une etmvariableload fixed (thing réel) + une dynamic + une relay-router.
QVariantList loadConfigs;
loadConfigs << QVariantMap{
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
@ -392,16 +394,26 @@ void Simulation::testLoadConfigRpc()
{"id", "{b033b212-1adb-4df0-ba2b-8fa477de52a2}"}, {"label", "Routeur PV"},
{"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000},
{"priority", 1}, {"enabled", true}};
loadConfigs << QVariantMap{ // ← rév. 3 : relay-router (relays[])
{"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 3}, {"enabled", true},
{"relays", QVariantList()
<< QVariantMap{{"thingId", "{aaaaaaaa-1111-2222-3333-444444444444}"}, {"powerW", 1000}}
<< QVariantMap{{"thingId", "{bbbbbbbb-1111-2222-3333-444444444444}"}, {"powerW", 1500}}},
{"minOnS", 60}, {"minOffS", 60}};
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", loadConfigs}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
// 3. GetLoadConfig → round-trip des 2 entrées, champs §4 préservés (via pack/unpack Q_GADGET).
// 3. GetLoadConfig → round-trip des 3 entrées (les 2 formes), champs préservés (pack/unpack).
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
const QVariantList got = resp.toMap().value("params").toMap().value("loadConfigs").toList();
QCOMPARE(got.size(), 2);
QVariantMap fixedGot;
for (const QVariant &v : got)
if (v.toMap().value("id").toString() == ecsId.toString()) fixedGot = v.toMap();
QCOMPARE(got.size(), 3);
QVariantMap fixedGot, relayGot;
for (const QVariant &v : got) {
const QVariantMap m = v.toMap();
if (m.value("id").toString() == ecsId.toString()) fixedGot = m;
if (m.value("adapter").toString() == "relay-router") relayGot = m;
}
QVERIFY(!fixedGot.isEmpty());
QCOMPARE(fixedGot.value("mode").toString(), QString("fixed"));
QCOMPARE(fixedGot.value("label").toString(), QString("Chauffe-eau"));
@ -409,6 +421,12 @@ void Simulation::testLoadConfigRpc()
QCOMPARE(levels.size(), 3);
QCOMPARE(levels.last().toInt(), 2400);
QCOMPARE(fixedGot.value("needs").toMap().value("dailyDeadline").toString(), QString("06:00"));
// Forme relay-router : relays[] + minOnS round-trip (jamais piloté ici — RelayRouter en étape 4).
QVERIFY(!relayGot.isEmpty());
QVariantList relays = relayGot.value("relays").toList();
QCOMPARE(relays.size(), 2);
QCOMPARE(relays.last().toMap().value("powerW").toInt(), 1500);
QCOMPARE(relayGot.value("minOnS").toInt(), 60);
// 4. Fichier persisté.
QVERIFY(QFileInfo::exists(cfgPath));
@ -421,17 +439,27 @@ void Simulation::testLoadConfigRpc()
QCoreApplication::processEvents();
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400); // reliquat 3000 → palier 2400
// 6. Config invalide (fixed sans 0) → rejet en bloc, energyError InvalidParameter.
QVariantList bad;
bad << QVariantMap{
// 6. Validation CONDITIONNELLE — rejet en bloc (energyError InvalidParameter) :
// (a) etmvariableload fixed sans 0 dans powerLevels ; (b) relay-router avec relays[] vide.
QVariantList badA;
badA << QVariantMap{
{"id", "{cccccccc-2222-3333-4444-555555555555}"}, {"label", "Bancal"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 1200 << 2400}, {"maxPowerW", 2400},
{"priority", 1}, {"enabled", true}};
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", bad}});
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badA}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
// Le rejet n'a rien écrasé : GetLoadConfig retourne toujours les 2 valides.
QVariantList badB;
badB << QVariantMap{
{"id", "ecs-vide"}, {"label", "ECS sans relais"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
{"relays", QVariantList()}}; // relays[] vide → invalide
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badB}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
// Aucun rejet n'a écrasé : GetLoadConfig retourne toujours les 3 valides.
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 2);
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 3);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);