// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "socketscheduler.h" #include Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy) namespace { // Rang d'un déclencheur : le plus significatif l'emporte quand plusieurs se cumulent. int rangDeclencheur(const QString &t) { static const QStringList ordre{QStringLiteral("cycle"), QStringLiteral("forecast"), QStringLiteral("config"), QStringLiteral("load-change"), QStringLiteral("intent")}; return ordre.indexOf(t); } } SocketScheduler::SocketScheduler(IScheduler *ombre, OptimizerClient *client, FournisseurFil fil, QObject *parent) : QObject(parent) , m_ombre(ombre) , m_client(client) , m_fil(std::move(fil)) { m_client->setParent(this); connect(m_client, &OptimizerClient::planReceived, this, &SocketScheduler::onPlanRecu); connect(m_client, &OptimizerClient::planFailed, this, &SocketScheduler::onPlanEchoue); // §7 « socket absent / fermé → rules, optimizerAlive = false » : un plan ne survit pas à la // connexion qui l'a porté, et un optimiseur qu'on n'atteint pas n'est pas vivant. // // La perte se note à la TRANSITION : sortie de Ready ou de Handshaking (fermeture, handshake // refusé), ou premier échec d'un client qui n'a pas encore été connecté (optimiseur absent dès // le départ : sans cela, lastErrorCode n'aurait jamais de valeur). Une tentative de reconnexion // qui échoue pendant une panne déjà notée n'en est pas une nouvelle : ni etatChange(), ni // dernier code réécrit (constat `drop`, brief 4 T7). connect(m_client, &OptimizerClient::stateChanged, this, [this](OptimizerClient::State s) { const bool etaitConnecte = m_etatClient == OptimizerClient::Ready || m_etatClient == OptimizerClient::Handshaking; m_etatClient = s; if (s == OptimizerClient::Ready) { m_perteNotee = false; return; } if (s == OptimizerClient::Connecting || s == OptimizerClient::Handshaking) return; // une tentative en cours n'est pas une perte if (!etaitConnecte && m_perteNotee) return; // tentative échouée pendant une panne déjà notée m_perteNotee = true; oublierCache(QStringLiteral("connexion perdue")); noterVivacite(OptimizerStatus::Vivacite::Evenement::ConnexionPerdue, m_client->lastErrorCode()); }); // Handshake accepté : nom, version et capacités ont changé — l'état publié se recompose. connect(m_client, &OptimizerClient::ready, this, &SocketScheduler::etatChange); connect(m_client, &OptimizerClient::notificationReceived, this, [this](const QString &method, const QVariantMap &) { onNotification(method); }); m_client->start(); } Plan SocketScheduler::getPlan(const SurplusContext &ctx) { // L'OMBRE D'ABORD, et toujours : ses effets de bord (proxy VE, comptabilité éco, réserve) // sont dus à chaque cycle, quelle que soit la stratégie qui commandera. const Plan ombre = m_ombre->getPlan(ctx); m_idDuCycle = 0; m_requeteDuCycle.clear(); envoyer(ctx); if (!m_aCache) { noterCycle(false, QStringLiteral("no-plan"), QStringLiteral("aucun plan en cache")); return ombre; } // Rejugé sur le contexte DE CE CYCLE : maintenant, charges, disponibilités ont pu changer // depuis la requête. const OptimizerPlan::Verdict v = OptimizerPlan::valider(m_cache, ctx, m_client->capabilities(), m_client->hasCapabilities()); if (v.issue != OptimizerPlan::Issue::Valide) { noterCycle(false, v.code, v.detail); if (v.issue == OptimizerPlan::Issue::Expire) oublierCache(QStringLiteral("plan expiré")); return ombre; } const Plan plan = OptimizerPlan::substituer(ombre, v.plan, ctx); if (plan.strategy != QLatin1String(OptimizerPlan::StrategieOptimiseur)) { noterCycle(false, QStringLiteral("no-shadow-slot"), QStringLiteral("l'ombre n'a pas de créneau courant")); return ombre; } m_planApplique = v.plan; journaliserCreneau(v, ctx); noterCycle(true, QString(), QString()); return plan; } void SocketScheduler::apresDispatch(Plan &plan, Slot &slot, const QHash &appliquees, const QSet &delestees, const SurplusContext &ctx) { if (plan.strategy != QLatin1String(OptimizerPlan::StrategieOptimiseur)) return; const QList ecretages = OptimizerPlan::finaliser(plan, slot, appliquees, delestees, ctx); QSet vus; for (const OptimizerPlan::Ecretage &e : ecretages) { vus.insert(e.loadId); const QString texte = e.demande + QStringLiteral(" → ") + e.applique; // Annoncé au CHANGEMENT : un palier qui arrondit la même demande à chaque cycle ne doit // pas produire une ligne par minute ; le premier écrêtage, lui, se dit en clair. if (m_ecretageVu.value(e.loadId) == texte) { qCDebug(dcNymeaEnergy()).noquote() << "[Optimiseur]" << e.loadId << "écrêtée (inchangé) :" << texte; continue; } m_ecretageVu.insert(e.loadId, texte); qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur]" << e.loadId << "ÉCRÊTÉE par l'adaptateur — demandé" << e.demande << ", appliqué" << e.applique << "(clamped)"; } for (auto it = m_ecretageVu.begin(); it != m_ecretageVu.end();) { if (vus.contains(it.key())) { ++it; continue; } qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur]" << it.key() << "n'est plus écrêtée"; it = m_ecretageVu.erase(it); } } void SocketScheduler::noterDeclencheur(const QString &trigger) { const int rang = rangDeclencheur(trigger); if (rang > rangDeclencheur(m_declencheur)) m_declencheur = trigger; } void SocketScheduler::envoyer(const SurplusContext &ctx) { if (m_client->state() != OptimizerClient::Ready) return; // le déclencheur attend la prochaine requête qui partira vraiment OptimizerProtocol::ContexteFil fil = m_fil ? m_fil(ctx) : OptimizerProtocol::ContexteFil(); fil.trigger = m_declencheur; const QVariantMap params = OptimizerProtocol::getPlanParams(ctx, fil); const quint64 id = m_client->requestPlan(params); if (id == 0) return; m_idRequete = id; m_ctxRequete = ctx; m_chronoRequete.start(); m_idDuCycle = id; m_requeteDuCycle = params; m_declencheur = QStringLiteral("cycle"); } void SocketScheduler::onPlanRecu(quint64 id, const QVariantMap &result) { if (id != m_idRequete) return; emit reponseTracee(id, QVariantMap{{QStringLiteral("result"), result}}, m_chronoRequete.elapsed()); const OptimizerPlan::Verdict v = OptimizerPlan::valider(result, m_ctxRequete, m_client->capabilities(), m_client->hasCapabilities()); signalerReserves(v.champsReserves); emit reponseJugee(OptimizerPlan::nomIssue(v.issue), v.code); // §7 : un plan valide rend la vie ; une abstention ou un plan déjà expiré rompent la série // d'échecs sans la rendre ; un plan invalide est un échec de la requête. switch (v.issue) { case OptimizerPlan::Issue::Valide: noterVivacite(OptimizerStatus::Vivacite::Evenement::PlanValide, QString(), m_ctxRequete.timestamp); break; case OptimizerPlan::Issue::Abstention: case OptimizerPlan::Issue::Expire: noterVivacite(OptimizerStatus::Vivacite::Evenement::Abstention, QString()); break; case OptimizerPlan::Issue::Invalide: noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, v.code); break; } switch (v.issue) { case OptimizerPlan::Issue::Valide: { m_cache = result; m_aCache = true; // Un optimiseur qui répond à chaque cycle redonne souvent le même plan : la ligne n'est en // clair qu'au CHANGEMENT de plan (ou au retour après un repli), sinon en debug. const bool nouveau = v.plan.planId != m_dernierPlanRecu || v.plan.planId.isEmpty(); m_dernierPlanRecu = v.plan.planId; const QString ligne = QStringLiteral("[Optimiseur] plan %1 reçu (GetPlan %2) — %3 créneau(x), %4 ; " "appliqué au cycle suivant") .arg(v.plan.planId.isEmpty() ? QStringLiteral("(sans id)") : v.plan.planId).arg(id) .arg(v.plan.creneaux.count()) .arg(v.plan.validUntil.isValid() ? QStringLiteral("valable jusqu'à ") + v.plan.validUntil.toString(Qt::ISODate) : QStringLiteral("sans validUntil")); if (nouveau) qCInfo(dcNymeaEnergy()).noquote() << ligne; else qCDebug(dcNymeaEnergy()).noquote() << ligne; return; } case OptimizerPlan::Issue::Invalide: m_dernierPlanRecu.clear(); // 7-d : un refus au moins aussi visible qu'une acceptation. Jamais appliqué en partie. qCWarning(dcNymeaEnergy()).noquote() << "[Optimiseur] plan REFUSÉ (GetPlan" << id << ") :" << v.code << "—" << v.detail << "— repli rule-based"; oublierCache(QString()); return; case OptimizerPlan::Issue::Abstention: case OptimizerPlan::Issue::Expire: m_dernierPlanRecu.clear(); qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] pas de plan applicable (GetPlan" << id << ") :" << v.code << "—" << v.detail << "— repli rule-based"; oublierCache(QString()); return; } } void SocketScheduler::onPlanEchoue(quint64 id, const QString &code) { if (id != m_idRequete) return; // Le client a déjà journalisé la cause. Le plan en cache, s'il y en a un, reste valable // jusqu'à son échéance : un échec ISOLÉ n'est pas un optimiseur mort. Le second l'est (§7). emit reponseTracee(id, QVariantMap{{QStringLiteral("error"), QVariantMap{{QStringLiteral("code"), code}}}}, m_chronoRequete.elapsed()); emit echecRequete(code); noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, code); } void SocketScheduler::noterVivacite(OptimizerStatus::Vivacite::Evenement e, const QString &code, const QDateTime &instant) { const bool change = m_vivacite.noter(e, code, instant); if (change && m_vivacite.vivant()) { qCInfo(dcNymeaEnergy()) << "[Optimiseur] optimiseur VIVANT (optimizerAlive = true) : plan valide reçu"; } else if (change) { // Mort : le plan en cache n'est plus cru, même s'il n'a pas expiré — le tableau §7 dit // « rules », pas « le dernier plan jusqu'à son échéance ». oublierCache(QString()); if (e == OptimizerStatus::Vivacite::Evenement::Echec) qCWarning(dcNymeaEnergy()).noquote() << "[Optimiseur] optimiseur déclaré MORT (optimizerAlive = false) :" << m_vivacite.echecsConsecutifs() << "requêtes consécutives en échec, dernière :" << m_vivacite.dernierCodeErreur() << "— repli rule-based jusqu'au prochain plan valide"; else qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] optimizerAlive = false : connexion absente ou fermée (" + (m_vivacite.dernierCodeErreur().isEmpty() ? QStringLiteral("-") : m_vivacite.dernierCodeErreur()) + ") — repli rule-based"; } emit etatChange(); } void SocketScheduler::onNotification(const QString &method) { if (method != QLatin1String("UpdateLearned")) return; // le client l'a déjà notée en debug ; aucune autre notification n'est définie (§8) // §8 : reçu, ignoré — la persistance des estimations apprises est un lot ultérieur. Le // contenu n'est jamais journalisé : il vient d'ailleurs et peut être volumineux. if (m_updateLearnedSignale) { qCDebug(dcNymeaEnergy()) << "[Optimiseur] UpdateLearned reçu et ignoré"; return; } m_updateLearnedSignale = true; qCInfo(dcNymeaEnergy()) << "[Optimiseur] UpdateLearned reçu et IGNORÉ (persistance hors de ce lot)" << "— signalé une fois par session"; } void SocketScheduler::oublierCache(const QString &pourquoi) { if (!m_aCache) return; m_aCache = false; m_cache.clear(); if (!pourquoi.isEmpty()) qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] plan en cache abandonné :" << pourquoi; } void SocketScheduler::noterCycle(bool optimiseur, const QString &code, const QString &detail) { // 7-c : l'issue est dite à chaque CHANGEMENT de stratégie, en clair ; le cycle ordinaire // reste en debug — une ligne par minute noierait le reste. const bool change = !m_cycleNote || optimiseur != m_optimiseurAuCycle || code != m_codeCycle; m_cycleNote = true; m_optimiseurAuCycle = optimiseur; if (!optimiseur) { // Au retour de l'optimiseur, ce qu'il dit se redit en clair. m_motifVu.clear(); m_indisponiblesVus.clear(); } m_codeCycle = code; if (change) { if (optimiseur) qCInfo(dcNymeaEnergy()) << "[Optimiseur] stratégie du cycle : optimiseur"; else qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] stratégie du cycle : rule-based (" + code + ") —" << detail; } else { qCDebug(dcNymeaEnergy()).noquote() << "[Optimiseur] cycle :" << (optimiseur ? QStringLiteral("optimiseur") : code); } emit strategieDuCycle(optimiseur, code); } void SocketScheduler::signalerReserves(const QStringList &champs) { for (const QString &c : champs) { if (m_reservesSignales.contains(c)) continue; m_reservesSignales.insert(c); qCWarning(dcNymeaEnergy()).noquote() << "[Optimiseur] champ" << c << "reçu et IGNORÉ (réservé ou interdit sur le fil, §6 / annexe F)" << "— signalé une fois par session"; } } void SocketScheduler::journaliserCreneau(const OptimizerPlan::Verdict &v, const SurplusContext &ctx) { const OptimizerPlan::CreneauRecu &c = v.plan.creneaux.at(v.plan.indexCourant); QHash libelles; for (const LoadContext &lc : ctx.loads) libelles.insert(lc.id, lc.label); // Le TEXTE de l'optimiseur vit ici, au journal — jamais dans l'action (invariant 12). Une // ligne en clair par charge quand ce que l'optimiseur DIT change (code, paramètres retirés, // texte) ; un plan recalculé chaque minute à l'identique ne répète rien. QStringList lignes; QSet vues; for (const OptimizerPlan::ActionRecue &ar : c.actions) { const QString ligne = ar.action.loadId + QStringLiteral(" → ") + renderFr(ar.action.reason, libelles.value(ar.action.loadId)) + QStringLiteral(" | reçu : ") + (ar.codeRecu.isEmpty() ? QStringLiteral("(sans code)") : ar.codeRecu) + (ar.codeSubstitue ? QStringLiteral(" → OPTIMIZER_DECISION") : QString()) + (ar.parametresSupprimes.isEmpty() ? QString() : QStringLiteral(" | paramètres supprimés : ") + ar.parametresSupprimes.join(QLatin1Char(','))) + QStringLiteral(" | « ") + ar.texte + QStringLiteral(" »"); vues.insert(ar.action.loadId); if (m_motifVu.value(ar.action.loadId) != ligne) { m_motifVu.insert(ar.action.loadId, ligne); lignes << ligne; } } for (auto it = m_motifVu.begin(); it != m_motifVu.end();) it = vues.contains(it.key()) ? std::next(it) : m_motifVu.erase(it); const QString entete = QStringLiteral("[Optimiseur] créneau %1 → %2 du plan %3 appliqué : %4 action(s)") .arg(c.from.toString(Qt::ISODate), c.to.toString(Qt::ISODate), v.plan.planId).arg(c.actions.count()); const QSet indisponibles(v.actionsSurIndisponibles.cbegin(), v.actionsSurIndisponibles.cend()); const bool indisponiblesChangent = indisponibles != m_indisponiblesVus; m_indisponiblesVus = indisponibles; if (lignes.isEmpty() && !indisponiblesChangent) { qCDebug(dcNymeaEnergy()).noquote() << entete << "(motifs inchangés)"; return; } qCInfo(dcNymeaEnergy()).noquote() << entete; for (const QString &l : lignes) qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] " << l; for (const QString &id : (indisponiblesChangent ? v.actionsSurIndisponibles : QStringList())) qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] " << id << ": charge INDISPONIBLE — action de l'optimiseur rejetée, " "celle du rule-based reste"; }