fix(optimiseur): token déclaré String, type contrôlé par le moteur ; GetPlan sous verrou (brief 4 T4)

Sous Qt 6, le validateur nymea ne contrôle pas le type des paramètres de base : la
déclaration Variant ne protégeait de rien (contre-épreuve inerte), le contrôle du handler
est la seule garde. Retour à String, plus lisible à l'introspection ; INTERFACE.md corrigé
(seule une clé EN TROP fait recopier les paramètres au journal par nymea).

testGetPlanShowsTheAppliedPowerUnderALock : sous le rule-based, un verrou minOn tient le
relais ; GetPlan publie l'appliqué. Avec l'écrêtage amont retiré, c'est la relecture de
l'appliqué qui protège seule (double mutation, elle mord).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-17 09:47:09 +02:00
parent 7a32961b23
commit e5926189e9
2 changed files with 37 additions and 5 deletions

View File

@ -575,10 +575,11 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
// WRITE-ONLY (décision Q2) : aucune méthode ne relit le token. Admin : c'est un secret, et il
// ouvre l'optimiseur à qui le détient — même niveau que les réglages d'installation du
// handler restés au défaut nymea (SetPhasePowerLimit, SetBatteryLevelConsideration).
// `token` est déclaré Variant, et son type vérifié ici : un paramètre que le VALIDATEUR nymea
// refuse est recopié en entier dans un avertissement (« Call params: », dcJsonRpc) — un token
// envoyé en nombre finirait au journal. Une clé en trop, elle, y finit quand même : le
// validateur la refuse avant que le handler ne voie l'appel (INTERFACE.md).
// Le TYPE de `token` est vérifié ici, pas par nymea : sous Qt 6, le validateur ne contrôle
// plus le type des paramètres de base (jsonvalidator.cpp, #if QT_VERSION >= 6), un nombre
// arrive donc au handler. Une clé EN TROP, elle, est refusée par le validateur, qui recopie
// alors les paramètres dans un avertissement (« Call params: », dcJsonRpc) — token compris,
// et hors de portée du plugin (INTERFACE.md).
params.clear(); returns.clear();
description = "Set the shared secret sent to the optimizer at handshake. WRITE-ONLY: no method "
"returns it (GetOptimizerStatus has tokenConfigured). Stored in its own file, mode "
@ -587,7 +588,7 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
"refusal: EnergyErrorInvalidParameter, fault token-refused, nothing is changed. "
"Send ONLY the 'token' key: nymea logs, as a warning, the params of a call whose "
"keys it rejects.";
params.insert("token", enumValueName(Variant));
params.insert("token", enumValueName(String));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
returns.insert("o:fault", enumValueName(String));
registerMethod("SetOptimizerToken", description, params, returns, Types::PermissionScopeAdmin);

View File

@ -115,6 +115,7 @@ private slots:
void testEndpointIsSetByRpcAndAppliedHot();
void testTokenIsWriteOnlyAndNeverLeaks();
void testGetPlanPublishesThePlanInForce();
void testGetPlanShowsTheAppliedPowerUnderALock();
void testUpdateLearnedIsLoggedOnceAndIgnored();
private:
@ -1332,6 +1333,36 @@ void TestSocketScheduler::testGetPlanPublishesThePlanInForce()
QCOMPARE(rpc("GetPlan"), (QVariantMap{{"inForce", false}}));
}
void TestSocketScheduler::testGetPlanShowsTheAppliedPowerUnderALock()
{
// Le créneau courant publie ce que l'adaptateur a APPLIQUÉ, pas ce que la stratégie a
// demandé. Sous l'optimiseur les deux coïncident déjà dans le créneau (finaliser(), T3) ;
// le cas qui les sépare est le rule-based face à un verrou interne à l'adaptateur.
monter(false);
QVariantList l;
l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"minOnS", 600}, {"minOffS", 0},
{"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
auto ecs = [this]() {
for (const QVariant &a : rpc("GetPlan").value("slots").toList().first().toMap().value("actions").toList())
if (a.toMap().value("loadId").toString() == QLatin1String("ecs"))
return a.toMap();
return QVariantMap();
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
QCOMPARE(qRound(ecs().value("powerW").toDouble()), 1000);
Thing *relais = NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais);
QCOMPARE(relais->stateValue("power").toBool(), true);
// Import : la stratégie veut couper, le verrou minOn (600 s) tient le relais fermé.
cycle(t0.addSecs(60), 2500);
QCOMPARE(relais->stateValue("power").toBool(), true);
QCOMPARE(qRound(ecs().value("powerW").toDouble()), 1000);
}
void TestSocketScheduler::testUpdateLearnedIsLoggedOnceAndIgnored()
{
// §8 : reçu, journalisé UNE fois, ignoré — la persistance est un lot ultérieur.