// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync // // Lot « contrat OpenRPC » — le protocole optimiseur est un CONTRAT MACHINE // (docs/etm-powersync-optimizer-protocol.openrpc.json), embarqué dans le plugin et servi par // NymeaEnergy.GetOptimizerProtocol. Cette suite tient les deux bouts du fil : // · le contrat embarqué est celui de docs/, octet pour octet ; // · ses listes fermées sont celles du CODE (x-etm-generated), et le code n'émet aucun code de // verdict, d'erreur ou de repli qui n'y figure pas — lu dans les SOURCES, pas dans une copie ; // · ses règles sémantiques sont les verdicts du validateur, dans son ordre ; // · le sérialiseur GetPlan, sur chacune de ses branches, produit ce que le contrat déclare — // jugé par le validateur publié (tools/etm-powersync-schema-check), pas par un second. // Logique pure : aucun serveur nymea, aucun arbitre (comme `loadmodel`). #include "etm/scheduler/optimizerclient.h" #include "etm/scheduler/optimizerplan.h" #include "etm/scheduler/optimizerprotocol.h" #include "etm/scheduler/socketscheduler.h" #include "etm/tariff/tariffconfig.h" #include "etm/types/decisionreason.h" #include "etm/types/loadconfig.h" #include "../common/etm-powersync-contrat.h" #include #include #include #include #include #include #include #include #include using namespace OptimizerProtocol; namespace { QJsonObject contrat() { return QJsonDocument::fromJson(schemaDocumentBytes()).object(); } //! Valeur au pointeur JSON \p pointeur (RFC 6901, sans échappement : le contrat n'en a pas besoin). QJsonValue aPointeur(const QJsonObject &doc, const QString &pointeur) { QJsonValue v = doc; for (const QString &morceau : pointeur.mid(1).split(QLatin1Char('/'))) { if (!v.isObject() || !v.toObject().contains(morceau)) return QJsonValue(QJsonValue::Undefined); v = v.toObject().value(morceau); } return v; } QJsonArray tableau(const QStringList &l) { return QJsonArray::fromStringList(l); } /*! * Les listes fermées du contrat, telles que le CODE les donne. La clé est le nom inscrit dans * `x-etm-generated.items[].from` ; l'outil de génération réécrit le contrat depuis ce dictionnaire. */ QJsonObject listesDuCode() { QJsonObject cles; for (const QString &code : DecisionCode::optimizerEmittableCodes()) cles.insert(code, tableau(DecisionCode::optimizerParamKeys(code))); return QJsonObject{ {"OptimizerProtocol::ProtocolVersion", QString::fromLatin1(ProtocolVersion)}, {"SocketScheduler::knownTriggers()", tableau(SocketScheduler::knownTriggers())}, {"LoadConfig::knownDomains()", tableau(LoadConfig::knownDomains())}, {"OptimizerPlan::kinds()", tableau(OptimizerPlan::kinds())}, {"DecisionCode::optimizerEmittableCodes()", tableau(DecisionCode::optimizerEmittableCodes())}, {"DecisionCode::optimizerParamKeys()", cles}, {"TariffConfig::includes()", tableau(TariffConfig::includes())}, {"SocketScheduler::knownFallbackCodes()", tableau(SocketScheduler::knownFallbackCodes())}, {"SocketScheduler::knownLastErrorCodes()", tableau(SocketScheduler::knownLastErrorCodes())}, }; } QString source(const QString &relatif) { QFile f(Contrat::racineSources() + QLatin1Char('/') + relatif); if (!f.open(QIODevice::ReadOnly)) return QString(); return QString::fromUtf8(f.readAll()); } //! Les captures \p groupe de \p motif dans \p texte. QSet captures(const QString &texte, const QString &motif, int groupe = 1) { QSet out; QRegularExpressionMatchIterator it = QRegularExpression(motif).globalMatch(texte); while (it.hasNext()) out.insert(it.next().captured(groupe)); return out; } QSet ensemble(const QStringList &l) { return QSet(l.cbegin(), l.cend()); } QString liste(const QSet &s) { QStringList l = s.values(); l.sort(); return l.join(QStringLiteral(", ")); } // --- Contextes : chaque branche du sérialiseur --------------------------------------------------- QDateTime instant() { return QDateTime(QDate(2026, 10, 12), QTime(13, 42), QTimeZone(7200)); } LoadContext enWatts(const QString &id, const QString &adapter, int priorite) { LoadContext lc; lc.id = id; lc.adapter = adapter; lc.label = id; lc.priority = priorite; lc.declared.minPowerW = adapter == QLatin1String("relay-router") ? 800 : 0; lc.declared.maxPowerW = 2400; lc.telemetry.currentPowerW = 1600; lc.telemetry.lockMinPowerW = 1600; lc.telemetry.lockMaxPowerW = -1; lc.limits.minOnS = 300; lc.limits.minOffS = 60; return lc; } LoadContext borne(bool avecAppareil, bool sessionMesuree) { LoadContext lc; lc.id = avecAppareil ? QStringLiteral("borne") : QStringLiteral("borne-sans-appareil"); lc.adapter = QStringLiteral("evcharger"); lc.label = lc.id; lc.priority = 1; if (avecAppareil) { lc.declared.minA = 6; lc.declared.maxA = 16; lc.declared.phases = 3; lc.telemetry.pluggedIn = true; lc.telemetry.charging = true; lc.telemetry.sessionMeasurable = sessionMesuree; lc.telemetry.sessionWh = 5200; lc.limits.chargingEnabledLockS = 300; lc.limits.currentChangeLockS = 60; lc.needs.targetSocPercent = 80; lc.needs.deadline = QDateTime(QDate(2026, 10, 13), QTime(7, 0), QTimeZone(7200)); } else { lc.telemetry.available = false; } lc.telemetry.currentPowerW = avecAppareil ? 4100 : 0; return lc; } LoadContext pac() { LoadContext lc; lc.id = QStringLiteral("pac"); lc.adapter = QStringLiteral("sg-ready"); lc.label = QStringLiteral("PAC"); lc.priority = 4; lc.declared.states = {1, 2, 3, 4}; lc.declared.estimatedPowerW = {{3, 1800}, {4, 2600}}; lc.telemetry.state = 3; lc.telemetry.minState = 3; lc.telemetry.maxState = 3; lc.limits.minStateHoldS = 900; return lc; } QVariantMap instantaneComplet() { QVariantList points, base, prix; for (int i = 0; i < 4; ++i) { if (i != 2) // une case sans point : null sur le fil points << QVariantMap{{"targetAt", QDateTime(QDate(2026, 10, 12), QTime(12 + i, 0), QTimeZone::UTC) .toString(Qt::ISODate)}, {"forecastWh", 1000.0 * (4 - i)}}; base << (i == 1 ? QVariant() : QVariant(400.0 + i)); prix << 20.5; } return { {"start", "2026-10-12T12:00:00Z"}, {"resolutionS", 3600}, {"steps", 4}, {"forecastPoints", points}, {"conversion", "physics"}, {"corrections", QStringList{"temperature", "ac-limit"}}, {"perField", QVariantList{QVariantMap{{"id", "plan-sud"}, {"corrections", QStringList{"temperature"}}}}}, {"model", "meteofrance_seamless"}, {"issuedAt", "2026-10-12T09:00:00Z"}, {"contentChangedAt", "2026-10-12T10:07:00Z"}, {"acLimitW", 8000}, {"baseload", QVariantMap{ {"model", "profile"}, {"p50W", base}, {"quality", QVariantMap{ {"windowDays", 28}, {"minDays", 7}, {"resolutionS", 3600}, {"daysCovered", QVariantMap{{"workday", 14}, {"weekend", 6}}}, {"minutes", 28800}, {"commandedMinutes", 30}, {"negativeMinutes", 0}, {"incompleteMinutes", 12}, {"computedAt", "2026-10-12T03:00:00Z"}, {"loads", QVariantList{ QVariantMap{{"loadId", "ecs"}, {"source", "meter"}, {"minutesBySource", QVariantMap{{"meter", 28000}, {"none", 800}}}, {"constantMeasurement", false}}, QVariantMap{{"loadId", "pac"}, {"source", "none"}, {"minutesBySource", QVariantMap{{"none", 28800}}}}}}}}}}, {"price", QVariantMap{{"currency", "EUR"}, {"importCtkWh", prix}, {"exportCtkWh", prix}, {"includes", TariffConfig::includes()}, {"source", "tou"}, {"knownUntil", "2026-10-12T16:00:00Z"}}}, }; } //! Tout ce que le sérialiseur sait émettre, une fois au moins. QVariantMap contexteComplet() { SurplusContext ctx; ctx.timestamp = instant(); ctx.site.contractedPowerW = 12000; ctx.meter.importW = 0; ctx.meter.exportW = 2310; ctx.meter.perPhaseA = {3.2, 4.1, 2.8}; ctx.battery.present = true; ctx.battery.socPercent = 62; ctx.battery.powerW = -800; ctx.battery.capacityWh = 10000; ctx.battery.maxChargeW = 5000; ctx.battery.maxDischargeW = 0; // inconnu : null ctx.tariff.provider = QStringLiteral("tempo"); ctx.tariff.current = {QDateTime(), QStringLiteral("HP_BLEU"), 16.1}; ctx.tariff.next = {{QDateTime(QDate(2026, 10, 13), QTime(6, 0), QTimeZone(7200)), QStringLiteral("ROUGE"), 75.6}, {QDateTime(), QStringLiteral("INCONNU"), 0}}; LoadContext ecs = enWatts(QStringLiteral("ecs"), QStringLiteral("relay-router"), 2); ecs.needs.dailyDeadline = QStringLiteral("18:00"); ecs.needs.minEnergyWhPerDay = 4000; ecs.derogation = true; ecs.derogationTargetWh = 1500.5; ecs.derogationEndsAt = instant().addSecs(3600); ecs.learned.dailyEnergyWh = 6400; ecs.learned.confidence = 0.35; ctx.loads = {borne(true, true), ecs, enWatts(QStringLiteral("variable"), QStringLiteral("etmvariableload"), 3), pac(), borne(false, false)}; ContexteFil fil; fil.trigger = QStringLiteral("load-change"); fil.meterSource = QStringLiteral("compteur-racine"); fil.pvCurrentW = 3400; fil.phaseLimitA = {63, 63, 63}; fil.reserveSocPercent = 15; fil.domaines = {{"borne", "ev"}, {"ecs", "ecs"}, {"pac", "heating"}}; fil.mesures.insert(QStringLiteral("borne"), MesureFil{QStringLiteral("device"), 4100}); fil.mesures.insert(QStringLiteral("ecs"), MesureFil{QStringLiteral("meter"), 1580}); fil.mesures.insert(QStringLiteral("pac"), MesureFil{QStringLiteral("none"), 0}); fil.prevision = instantaneComplet(); return getPlanParams(ctx, fil); } //! Le minimum : aucun stockage, rien de connu du fil, prévision en faute (pv null, prix null). QVariantMap contexteMinimal() { SurplusContext ctx; ctx.timestamp = instant().toUTC(); ctx.meter.importW = 1200; ctx.loads = {enWatts(QStringLiteral("seule"), QStringLiteral("etmvariableload"), 1)}; ContexteFil fil; QVariantList nuls; for (int i = 0; i < 3; ++i) nuls << QVariant(); fil.prevision = QVariantMap{{"start", "2026-10-11T22:00:00Z"}, {"resolutionS", 3600}, {"steps", 3}, {"baseload", QVariantMap{{"model", "none"}, {"p50W", nuls}}}, {"price", QVariant()}, {"fault", "MISSING_FORECAST_SITE"}}; return getPlanParams(ctx, fil); } //! Sans instantané du tout (forecast: null) ; borne qui ne compte pas sa session ; charge figée. QVariantMap contexteSansPrevision() { SurplusContext ctx; ctx.timestamp = instant(); LoadContext fige = enWatts(QStringLiteral("figee"), QStringLiteral("relay-router"), 2); fige.telemetry.available = false; fige.telemetry.lockMaxPowerW = 1600; ctx.loads = {borne(true, false), fige}; return getPlanParams(ctx, ContexteFil()); } QJsonObject requete(int id, const QVariantMap ¶ms) { return QJsonObject{{"jsonrpc", "2.0"}, {"id", id}, {"method", "GetPlan"}, {"params", QJsonObject::fromVariantMap(params)}}; } } // namespace class TestProtocolSchema : public QObject { Q_OBJECT private slots: void testTheEmbeddedContractIsTheDocumentedOne(); void testClosedListsAreGeneratedFromTheCode(); void testEveryCodeTheSourceEmitsIsListed(); void testSemanticRulesAreTheValidatorVerdicts(); void testEverySerializerBranchIsInTheContract(); }; void TestProtocolSchema::testTheEmbeddedContractIsTheDocumentedOne() { // Servi par le moteur qui tourne : la ressource Qt, jamais un fichier relu à l'exécution. Elle // doit être celle de docs/ — une ressource périmée servirait un contrat que personne ne relit. const QByteArray embarque = schemaDocumentBytes(); QVERIFY2(!embarque.isEmpty(), "ressource du contrat absente du binaire"); QFile f(Contrat::schemaDuDepot()); QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable(f.fileName())); QVERIFY2(f.readAll() == embarque, "le contrat EMBARQUÉ diffère de docs/ — build à refaire"); const QJsonObject doc = contrat(); QCOMPARE(doc.value("openrpc").toString(), QString("1.3.2")); QCOMPARE(doc.value("info").toObject().value("version").toString(), QString::fromLatin1(ProtocolVersion)); QCOMPARE(schemaDocument(), doc.toVariantMap()); QStringList methodes; for (const QJsonValue &m : doc.value("methods").toArray()) methodes << m.toObject().value("name").toString(); QCOMPARE(methodes, (QStringList{"Handshake", "GetPlan", "UpdateLearned"})); } void TestProtocolSchema::testClosedListsAreGeneratedFromTheCode() { const QJsonObject code = listesDuCode(); // Régénération : la liste du code, écrite là où l'outil la lira (tools/etm-powersync-gen-protocol-doc // --enums-from). Écrite AVANT la comparaison : c'est quand elles divergent qu'on en a besoin. const QByteArray sortie = qgetenv("ETM_POWERSYNC_CODE_LISTS"); if (!sortie.isEmpty()) { QFile f(QString::fromLocal8Bit(sortie)); QVERIFY(f.open(QIODevice::WriteOnly)); f.write(QJsonDocument(code).toJson(QJsonDocument::Indented)); } const QJsonObject doc = contrat(); const QJsonArray items = doc.value("x-etm-generated").toObject().value("items").toArray(); // L'ancre est ICI, pas dans le contrat : retirer une entrée de x-etm-generated ne fait pas // disparaître le contrôle, il casse. QSet sources; for (const QJsonValue &i : items) sources.insert(i.toObject().value("from").toString()); QCOMPARE(liste(sources), liste(ensemble(code.keys()))); QStringList ecarts; for (const QJsonValue &i : items) { const QString pointeur = i.toObject().value("pointer").toString(); const QString de = i.toObject().value("from").toString(); const QJsonValue dans = aPointeur(doc, pointeur); if (dans != code.value(de)) ecarts << QStringLiteral("%1 ← %2\n contrat : %3\n code : %4") .arg(pointeur, de, QString::fromUtf8(QJsonDocument(QJsonArray{dans}).toJson(QJsonDocument::Compact)), QString::fromUtf8(QJsonDocument(QJsonArray{code.value(de)}).toJson(QJsonDocument::Compact))); } QVERIFY2(ecarts.isEmpty(), qUtf8Printable(QStringLiteral("le contrat et le code divergent — régénérer :\n" " ETM_POWERSYNC_CODE_LISTS=/tmp/listes.json " "testClosedListsAreGeneratedFromTheCode\n" " tools/etm-powersync-gen-protocol-doc --enums-from /tmp/listes.json\n") + ecarts.join(QLatin1Char('\n')))); } void TestProtocolSchema::testEveryCodeTheSourceEmitsIsListed() { // Les listes de code ne sont pas le code : un refus ajouté au validateur sans entrée dans la // liste échapperait au contrat. On lit donc les SOURCES — la seule chose qui subsiste quand la // liste oublie (AGENTS.md, « le contrôle qui disparaît »). const QString plan = source(QStringLiteral("energyplugin/etm/scheduler/optimizerplan.cpp")); const QString client = source(QStringLiteral("energyplugin/etm/scheduler/optimizerclient.cpp")); const QString socket = source(QStringLiteral("energyplugin/etm/scheduler/socketscheduler.cpp")); const QString arbitre = source(QStringLiteral("energyplugin/etm/energyarbitrator.cpp")); QVERIFY(!plan.isEmpty() && !client.isEmpty() && !socket.isEmpty() && !arbitre.isEmpty()); // Verdicts : refus(Issue::X, QStringLiteral("code") …) et les retours de convertirAction(). using OptimizerPlan::Issue; const QHash issues{{"Abstention", Issue::Abstention}, {"Expire", Issue::Expire}, {"Invalide", Issue::Invalide}}; QSet lus; QRegularExpressionMatchIterator it = QRegularExpression(QStringLiteral("refus\\(Issue::(\\w+),\\s*QStringLiteral\\(\"([a-z-]+)\"\\)")).globalMatch(plan); QStringList mauvaiseIssue; while (it.hasNext()) { const QRegularExpressionMatch m = it.next(); lus.insert(m.captured(2)); if (!OptimizerPlan::verdictCodes(issues.value(m.captured(1))).contains(m.captured(2))) mauvaiseIssue << m.captured(2) + QStringLiteral(" (") + m.captured(1) + QLatin1Char(')'); } const int debut = plan.indexOf(QStringLiteral("QString convertirAction(")); const int fin = plan.indexOf(QStringLiteral("QString decrire("), debut); QVERIFY(debut > 0 && fin > debut); for (const QString &c : captures(plan.mid(debut, fin - debut), QStringLiteral("return QStringLiteral\\(\"([a-z-]+)\"\\);"))) { lus.insert(c); if (!OptimizerPlan::verdictCodes(Issue::Invalide).contains(c)) mauvaiseIssue << c + QStringLiteral(" (action : Invalide)"); } QVERIFY2(lus.size() >= 14, qUtf8Printable(QStringLiteral("lecture des sources suspecte : %1 code(s)").arg(lus.size()))); QCOMPARE(liste(lus), liste(ensemble(OptimizerPlan::verdictCodes()))); QVERIFY2(mauvaiseIssue.isEmpty(), qUtf8Printable(mauvaiseIssue.join(", "))); // Client : chaque code retenu ou émis. QSet erreurs = captures(client, QStringLiteral("(?:failConnection|countProtocolError)\\(QStringLiteral\\(\"([a-z-]+)\"\\)")); erreurs += captures(client, QStringLiteral("m_lastErrorCode = QStringLiteral\\(\"([a-z-]+)\"\\)")); erreurs += captures(client, QStringLiteral("planFailed\\([^;]*QStringLiteral\\(\"([a-z-]+)\"\\)")); QVERIFY2(erreurs.size() >= 15, qUtf8Printable(QStringLiteral("lecture des sources suspecte : %1 code(s)").arg(erreurs.size()))); QCOMPARE(liste(erreurs), liste(ensemble(OptimizerClient::knownErrorCodes()))); // Repli publié : chaque noterCycle(false, "code") du SocketScheduler. const QSet replis = captures(socket, QStringLiteral("noterCycle\\(false,\\s*QStringLiteral\\(\"([a-z-]+)\"\\)")); QCOMPARE(liste(replis), QString("no-plan, no-shadow-slot")); QVERIFY(ensemble(SocketScheduler::knownFallbackCodes()).contains(replis)); // Dernier code publié par l'arbitre lui-même. const QSet publies = captures(arbitre, QStringLiteral("\"lastErrorCode\"\\),\\s*QStringLiteral\\(\"([a-z-]+)\"\\)")); QCOMPARE(liste(publies), QString("invalid-endpoint")); QVERIFY(ensemble(SocketScheduler::knownLastErrorCodes()).contains(publies)); } void TestProtocolSchema::testSemanticRulesAreTheValidatorVerdicts() { const QJsonObject doc = contrat(); const QJsonArray regles = doc.value("x-etm-semantic-rules").toObject().value("rules").toArray(); const QStringList replis = SocketScheduler::knownFallbackCodes(); const QStringList erreurs = SocketScheduler::knownLastErrorCodes(); const QHash issue{{"abstention", "Abstention"}, {"expired", "Expire"}, {"invalid", "Invalide"}}; QStringList noms; for (const QJsonValue &v : regles) { const QJsonObject r = v.toObject(); const QString nom = r.value("name").toString(); QVERIFY2(replis.contains(r.value("fallbackCode").toString()), qUtf8Printable(nom)); if (r.contains("lastErrorCode")) QVERIFY2(erreurs.contains(r.value("lastErrorCode").toString()), qUtf8Printable(nom)); if (r.value("method").toString() != QLatin1String("GetPlan")) { QCOMPARE(r.value("issue").toString(), QString("connection-refused")); QVERIFY2(OptimizerClient::knownErrorCodes().contains(nom), qUtf8Printable(nom)); continue; } noms << nom; QCOMPARE(r.value("fallbackCode").toString(), nom); const OptimizerPlan::Issue attendue = nom == QLatin1String("expired") ? OptimizerPlan::Issue::Expire : OptimizerPlan::verdictCodes(OptimizerPlan::Issue::Abstention).contains(nom) ? OptimizerPlan::Issue::Abstention : OptimizerPlan::Issue::Invalide; QVERIFY2(OptimizerPlan::verdictCodes(attendue).contains(nom), qUtf8Printable(nom)); QCOMPARE(issue.value(r.value("issue").toString()), QString(attendue == OptimizerPlan::Issue::Expire ? "Expire" : attendue == OptimizerPlan::Issue::Abstention ? "Abstention" : "Invalide")); QCOMPARE(r.value("countsAsFailure").toBool(), attendue == OptimizerPlan::Issue::Invalide); } // Même ensemble, même ORDRE : l'ordre des contrôles décide du code qu'un plan fautif reçoit. QCOMPARE(noms, OptimizerPlan::verdictCodes()); } void TestProtocolSchema::testEverySerializerBranchIsInTheContract() { // Les requêtes sont FERMÉES dans le contrat (additionalProperties: false) : un champ ajouté au // sérialiseur sans le contrat fait échouer ce test — sur chaque branche, pas seulement celles // des fichiers de référence de `loadmodel`. const QList requetes{requete(1, contexteComplet()), requete(2, contexteMinimal()), requete(3, contexteSansPrevision())}; // Garde de pertinence : les branches visées sont bien PRÉSENTES dans ce qui est jugé. const QJsonObject complet = requetes.at(0).value("params").toObject(); QVERIFY(complet.value("forecast").toObject().value("pv").toObject().contains("perField")); QVERIFY(complet.value("forecast").toObject().value("price").isObject()); QVERIFY(complet.value("tariff").isObject()); QVERIFY(complet.value("battery").toObject().value("present").toBool()); QCOMPARE(complet.value("loads").toArray().size(), 5); QVERIFY(requetes.at(1).value("params").toObject().value("forecast").toObject().value("pv").isNull()); QVERIFY(requetes.at(2).value("params").toObject().value("forecast").isNull()); QTemporaryDir dir; QVERIFY(dir.isValid()); const Contrat::Verdict v = Contrat::juger(requetes, Contrat::ecrireSchema(dir, schemaDocumentBytes()), {QStringLiteral("--requests-only")}); QVERIFY2(v.code == 0, qUtf8Printable(v.sortie)); QCOMPARE(v.compte("GetPlan.params"), 3); } QTEST_GUILESS_MAIN(TestProtocolSchema) #include "testprotocolschema.moc"