Patrick Schurig c1ce67942c feat(optimiseur): machine de repli §7, état et plan publiés vers l'app (brief 4 T4)
- OptimizerStatus::Vivacite : optimizerAlive, échecs comptés PAR REQUÊTE (E17), deux
  consécutifs = mort, socket fermé = mort, retour au premier plan valide ; une abstention
  rompt la série sans ressusciter. strategieActive() : none sous L2, prioritaire sur tout ;
  rules dès qu'un optimiseur n'est plus vivant, sans attendre le cycle suivant.
- SocketScheduler : branchement de la vivacité, cache abandonné à la mort, UpdateLearned
  journalisé une fois et ignoré.
- NymeaEnergy.GetOptimizerStatus + OptimizerStatusChanged (une notification par
  transition, charge utile déclarée une fois) ; champs installateur absents s'ils sont
  inconnus ; jamais le token, tokenConfigured seulement. Pas de tier (Q5).
- SetOptimizerEndpoint (persisté, appliqué à chaud) et SetOptimizerToken (écriture seule,
  fichier 0600, Q2), tous deux PermissionScopeAdmin ; token déclaré Variant pour qu'un
  mauvais type ne soit pas recopié au journal par le validateur nymea.
- NymeaEnergy.GetPlan : plan en vigueur (appliqué au créneau courant, demandé ensuite),
  codes et paramètres filtrés seulement, inForce toujours présent, fallbackCode quand un
  optimiseur configuré n'a pas commandé.
- Tests : tableau §7 ligne par ligne, transitions notifiées une fois, RPC, token absent de
  tout journal du moteur et de toute réponse.

Sans endpoint : decisions IDENTIQUE (23 792 lignes), suites inchangées, journal >= info
inchangé (8 739 lignes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:34:39 +02:00

111 lines
5.3 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QString>
#include <QVariantMap>
/*!
* \file optimizerstatus.h
* \brief Machine de repli §7 (OPTIMIZER_PROTOCOL.md 1.1) et ce qu'elle publie vers l'app
* (\c NymeaEnergy.GetOptimizerStatus, brief 4 T4).
*
* Logique PURE : ni E/S, ni horloge (les instants sont fournis par l'appelant), ni accès à
* l'arbitre. Le \c SocketScheduler y verse les événements de sa connexion ; l'arbitre compose
* l'état publié (attendu, vivant, stratégie, mode dégradé L2).
*/
namespace OptimizerStatus {
//! \c activeStrategy : le créneau courant d'un plan d'optimiseur a commandé le dernier cycle.
constexpr const char *StrategieOptimiseur = "optimizer";
//! \c activeStrategy : le rule-based a commandé le dernier cycle (seul, ou en repli).
constexpr const char *StrategieRegles = "rules";
//! \c activeStrategy : planification suspendue — mode dégradé L2, prioritaire sur tout (§7).
constexpr const char *StrategieAucune = "none";
//! Requêtes consécutives en échec au-delà desquelles l'optimiseur est déclaré mort (§4, §7, E17).
constexpr int EchecsAvantMort = 2;
/*!
* \brief Vivacité de l'optimiseur — la colonne \c optimizerAlive du tableau §7.
*
* | Événement | \c vivant() | échecs consécutifs |
* |---|---|---|
* | plan valide reçu | vrai | remis à 0 |
* | abstention, plan expiré à la réception | inchangé | remis à 0 |
* | requête en échec (délai, erreur, réponse illisible, plan invalide) | faux au 2ᵉ | +1 |
* | connexion absente ou fermée | faux | inchangé |
*
* Les échecs se comptent par REQUÊTE, pas par cycle (écart E17) : une requête remplacée par une
* plus récente avant sa réponse n'est pas un échec (le client n'émet rien, brief 4 T2).
* \invariant Seul un plan valide rend la vie : une abstention dit que l'optimiseur répond, pas
* qu'il planifie, et ne ressuscite donc pas un optimiseur déclaré mort.
*/
class Vivacite
{
public:
/*! \brief Ce qui arrive à la connexion ou à une requête. */
enum class Evenement {
PlanValide, //!< Réponse \c GetPlan jugée valide.
Abstention, //!< Abstention, plan vide, aucun créneau courant, ou plan expiré à la réception.
Echec, //!< Requête sans réponse utilisable, ou plan invalide.
ConnexionPerdue //!< Socket absent, fermé, ou handshake refusé.
};
/*!
* \brief Applique un événement.
* \param e Événement.
* \param code Code d'erreur retenu comme \c lastErrorCode (\c Echec, \c ConnexionPerdue) ;
* vide = inchangé.
* \param instant Instant du plan valide (\c lastPlanAt) ; ignoré pour les autres événements.
* \return Vrai si \c vivant() a changé.
*/
bool noter(Evenement e, const QString &code = QString(), const QDateTime &instant = QDateTime());
/*! \brief \return \c optimizerAlive. Faux tant qu'aucun plan valide n'a été reçu. */
bool vivant() const { return m_vivant; }
/*! \brief \return Requêtes consécutives en échec depuis la dernière réponse valide. */
int echecsConsecutifs() const { return m_echecs; }
/*! \brief \return Dernier code d'erreur vu (requête, plan ou connexion) ; vide si aucun. */
QString dernierCodeErreur() const { return m_dernierCode; }
/*! \brief \return Instant du cycle dont la requête a reçu le dernier plan valide ; invalide si aucun. */
QDateTime dernierPlanA() const { return m_dernierPlanA; }
private:
bool m_vivant = false;
int m_echecs = 0;
QString m_dernierCode;
QDateTime m_dernierPlanA;
};
/*!
* \brief La colonne « stratégie active » du tableau §7, composée sans attendre le cycle suivant.
* \param degrade Mode dégradé L2 en cours. \param vivant \c optimizerAlive.
* \param strategieDuCycle Stratégie qui a commandé le dernier cycle planifié (\c optimizer,
* \c rules, ou \c none si aucun cycle n'a été planifié depuis l'entrée en L2).
* \return \c none sous L2, prioritaire sur tout ; \c rules si le dernier cycle était à
* l'optimiseur mais qu'il n'est plus vivant (mort, connexion perdue, endpoint retiré) — son
* plan en cache a été abandonné, le cycle suivant sera au rule-based, et publier
* « optimizer » d'ici là décrirait un état qui n'existe plus ; sinon \p strategieDuCycle.
*/
QString strategieActive(bool degrade, bool vivant, const QString &strategieDuCycle);
/*!
* \brief \c capabilities du handshake réduites à ce que l'app peut afficher (mode installateur).
* \param capabilities \c result.capabilities tel que reçu.
* \return Les seules clés documentées au §3 — \c maxHorizonH, \c resolutionS (nombres finis),
* \c kinds, \c usesForecast (listes de codes connus), \c reasonCodes (booléen). Toute autre clé
* ou valeur est retirée : rien de ce qu'un optimiseur écrit librement n'atteint l'app.
*/
QVariantMap capacitesPubliees(const QVariantMap &capabilities);
/*!
* \brief Chaîne d'identité d'un optimiseur (\c name, \c protocolVersion) rendue sûre pour l'app.
* \param texte Valeur reçue au handshake. \param max Longueur gardée.
* \return Caractères de contrôle retirés, blancs resserrés, coupée à \p max ; vide si rien ne reste.
*/
QString identite(const QString &texte, int max);
}