diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 4205258..6ee16c2 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -64,15 +64,7 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, // Le magasin n'est PAS un déclencheur d'horloge murale : rien ne justifiait qu'il y soit. // Sa purge, si — elle reste dans la garde ci-dessous. m_forecastStore = new ForecastStore(ForecastStore::cheminParDefaut(), this); - { - EnergySettings reglages; - m_forecastSiteId = reglages.value(QStringLiteral("forecastSiteId")).toString(); - m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen")); - bool ok = false; - const int plafond = reglages.value(QStringLiteral("forecastAcLimitW")).toInt(&ok); - if (ok && plafond >= AcLimitMinW && plafond <= AcLimitMaxW) - m_forecastAcLimitW = plafond; - } + relireReglagesPrevision(); #ifndef ENERGY_SIMULATION m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat) @@ -2316,8 +2308,34 @@ void EnergyArbitrator::signalerSiteManquant() .arg(QLatin1String(DecisionCode::MissingForecastSite)); } -bool EnergyArbitrator::setForecastSiteId(const QString &siteId, std::optional acLimitW) +QString EnergyArbitrator::siteIdCanonique(const QString &siteId) { + // LA FORME DE `parentId().toString()` — accolades, minuscules —, parce que c'est à elle que + // le site est comparé, EN CHAÎNE, dans `forecastSnapshot()` et `lireCourbePrevision()`. + // Constaté le 2026-10-13 : un UUID reçu sans accolades était accepté (`ThingId` le lit), + // stocké tel quel, et ne correspondait ensuite à aucun plan — snapshot muet, table vide. + // Ce qui n'est pas un UUID est rendu tel quel : il ne correspondra à rien, comme avant. + const ThingId id(siteId); + return id.isNull() ? siteId : id.toString(); +} + +void EnergyArbitrator::relireReglagesPrevision() +{ + EnergySettings reglages; + // NORMALISÉE À LA RELECTURE AUSSI : une version antérieure a pu persister la forme sans + // accolades. La réécrire n'est pas nécessaire — la prochaine désignation le fera. + m_forecastSiteId = siteIdCanonique(reglages.value(QStringLiteral("forecastSiteId")).toString()); + m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen")); + m_forecastAcLimitW.reset(); + bool ok = false; + const int plafond = reglages.value(QStringLiteral("forecastAcLimitW")).toInt(&ok); + if (ok && plafond >= AcLimitMinW && plafond <= AcLimitMaxW) + m_forecastAcLimitW = plafond; +} + +bool EnergyArbitrator::setForecastSiteId(const QString &siteIdRecu, std::optional acLimitW) +{ + const QString siteId = siteIdCanonique(siteIdRecu); if (!siteId.isEmpty() && (!m_tm || !m_tm->findConfiguredThing(ThingId(siteId)))) return false; // Un plafond est une propriété d'un SITE : sans site, il n'aurait pas d'objet. Et ZÉRO n'est diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index aa44e6c..d1643fc 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -108,9 +108,30 @@ public: * \c SetForecastSite, lui, lit « absent = inchangé » : c'est le handler qui fusionne. * \return Faux si l'identifiant n'est pas celui d'un Thing existant, si le plafond est hors * de [\c AcLimitMinW, \c AcLimitMaxW], ou s'il est donné sans site. Rien n'est alors modifié. + * \note L'identifiant est NORMALISÉ (\c siteIdCanonique) avant contrôle, stockage et + * persistance : avec ou sans accolades, majuscules ou non, c'est le même site, et + * \c forecastSiteId() le rend toujours sous la forme \c {uuid} en minuscules. */ bool setForecastSiteId(const QString &siteId, std::optional acLimitW = std::nullopt); + /*! + * \brief Forme canonique d'un identifiant de site : celle de \c ThingId::toString(). + * \param siteId UUID avec ou sans accolades, casse indifférente ; ou vide. + * \return \c {uuid} en minuscules ; vide si \p siteId est vide ; \p siteId inchangé s'il + * n'est pas un UUID (il ne désignera alors aucun Thing). + * \note Le site est comparé EN CHAÎNE à \c parentId().toString() : toute valeur stockée + * dans \c m_forecastSiteId passe par ici, à l'écriture comme à la relecture. + */ + static QString siteIdCanonique(const QString &siteId); + + /*! + * \brief Relit la désignation persistée (site, choix exprimé, plafond AC). + * \note Appelée par le constructeur. Exposée pour le banc : c'est ainsi qu'il prouve qu'une + * valeur persistée par une version antérieure (UUID sans accolades) est relue normalisée, + * sans redémarrer un serveur dont le harnais efface les réglages. + */ + void relireReglagesPrevision(); + /*! * \brief Les sites CANDIDATS — ceux dont au moins un enfant publie une courbe. * \return Liste de ThingId, vide s'il n'y en a aucun. diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 1433c63..e431abe 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -7281,9 +7281,8 @@ void Simulation::testTheSnapshotPublishesEachPlanesCorrections() tOuest->setParentId(ThingId()); arb->setForecastSiteId(QString()); }); - // L'identifiant AVEC accolades, tel que `candidateSiteThingIds` le rend. ⚠️ Sans accolades, - // la désignation est acceptée mais le snapshot reste muet : `forecastSnapshot()` compare des - // CHAÎNES (défaut préexistant, signalé dans le rapport des décisions du 2026-10-13). + // L'identifiant AVEC accolades, tel que `candidateSiteThingIds` le rend. La forme sans + // accolades est tenue à part, par `testTheForecastSiteIdIsMatchedWhateverItsBraces`. const QString siteId = ThingId(site).toString(); const qint64 t0 = QDateTime(QDate(2026, 10, 13), QTime(10, 0), QTimeZone::utc()).toSecsSinceEpoch(); @@ -7356,6 +7355,88 @@ void Simulation::testTheSnapshotPublishesEachPlanesCorrections() #endif } +void Simulation::testTheForecastSiteIdIsMatchedWhateverItsBraces() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Constaté le 2026-10-13 (rapport des décisions, écart n° 1) : `SetForecastSite` stockait + // l'identifiant TEL QUE REÇU, puis le comparait EN CHAÎNE à `parentId().toString()`, qui a + // des accolades. Un UUID envoyé sans elles était accepté — et le snapshot restait MUET, la + // table vide. Une désignation qui a l'air faite et ne fait rien. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid site = addMeter(); // n'importe quel Thing configuré tient lieu de site + const QUuid plan = addPvPlane(QStringLiteral("Toit")); + Thing *tPlan = tm->findConfiguredThing(ThingId(plan)); + QVERIFY(tPlan && !site.isNull()); + tPlan->setParentId(ThingId(site)); + const auto detacher = qScopeGuard([&]() { + tPlan->setParentId(ThingId()); + arb->setForecastSiteId(QString()); + }); + const qint64 t0 = QDateTime(QDate(2026, 10, 13), QTime(10, 0), QTimeZone::utc()).toSecsSinceEpoch(); + tPlan->setStateValue("forecastCurveTransport", + QVariantMap{{"v", 2}, {"unit", "W"}, {"convention", "mean over [t, t+step)"}, + {"t0", t0}, {"step", 3600}, {"issuedAt", t0 - 3600}, + {"model", "meteofrance_seamless"}, {"p", QVariantList{1500, 1200}}, + {"corrections", QVariantList{}}}); + + const QString canonique = ThingId(site).toString(); // {uuid}, minuscules + QVERIFY(canonique.startsWith('{')); + const QString nu = site.toString(QUuid::WithoutBraces); + auto snapshot = [&]() { + return injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap(); + }; + auto courbeLue = [&](const char *forme) { + QString motif; QVariantMap params; + const ForecastRevision rev = arb->forecastSnapshot(motif, params); + QVERIFY2(motif.isEmpty(), qUtf8Printable(QStringLiteral("%1 : motif %2").arg(forme, motif))); + QVERIFY2(rev.points.count() == 2, forme); + QCOMPARE(rev.siteId, canonique); + const QVariantMap snap = snapshot(); + QVERIFY2(snap.value("forecastPoints").toList().count() == 2, + qUtf8Printable(QStringLiteral("%1 : %2").arg(forme, + QString::fromUtf8(QJsonDocument::fromVariant(snap).toJson())))); + QCOMPARE(injectAndWait("NymeaEnergy.GetForecastSite").toMap().value("params").toMap() + .value("forecastSiteThingId").toString(), canonique); + QCOMPARE(arb->forecastSiteId(), canonique); + }; + + // 1. PAR LE RPC, avec puis sans accolades, puis en majuscules : le même site, la même courbe, + // et la désignation est rendue — et persistée — sous UNE seule forme. + for (const QString &forme : {canonique, nu, nu.toUpper()}) { + const QVariantMap r = injectAndWait("NymeaEnergy.SetForecastSite", + {{"forecastSiteThingId", forme}}).toMap(); + QCOMPARE(r.value("params").toMap().value("energyError").toString(), + QStringLiteral("EnergyErrorNoError")); + courbeLue(qPrintable(forme)); + QCOMPARE(EnergySettings().value("forecastSiteId").toString(), canonique); + } + + // 2. Par l'arbitre directement, sans accolades : même normalisation, même chemin. + QVERIFY(arb->setForecastSiteId(nu)); + courbeLue("setForecastSiteId sans accolades"); + + // 3. UNE VALEUR PERSISTÉE PAR UNE VERSION ANTÉRIEURE, sans accolades, puis relue : c'est le + // cas d'une box mise à jour, dont le réglage a été écrit avant le correctif. + QVERIFY(arb->setForecastSiteId(QString()) && arb->forecastSiteId().isEmpty()); + EnergySettings().setValue(QStringLiteral("forecastSiteId"), nu); + arb->relireReglagesPrevision(); + courbeLue("valeur persistée sans accolades"); + + // 4. Contre-épreuve : un AUTRE site, sous quelque forme que ce soit, ne lit pas ce plan. + // Le plan lui-même tient lieu d'autre site : un Thing existant, parent d'aucun plan. + QVERIFY(arb->setForecastSiteId(plan.toString(QUuid::WithoutBraces))); + QString motif; QVariantMap params; + QVERIFY(arb->forecastSnapshot(motif, params).points.isEmpty()); +#endif +} + void Simulation::testAnUnreadableForecastDateIsAnError() { // Décision du 2026-10-13 (§30.4). Constaté le 2026-10-12 : une date illisible devenait diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 2553309..cc7ef64 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -214,6 +214,7 @@ private slots: //! Brief 3 T1 — acLimitW voyage avec la désignation de site, absent = aucun plafond. void testTheSiteAcLimitTravelsWithTheDesignation(); void testTheSnapshotPublishesEachPlanesCorrections(); + void testTheForecastSiteIdIsMatchedWhateverItsBraces(); void testAnUnreadableForecastDateIsAnError(); //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet. void testEveryGetterReturnsWhatItDeclares();