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:
parent
1f976f0189
commit
5585a5c483
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user