Doxygen : 1 avertissement introduit par 5768c36 (paramètre non documenté) ; 0 après. Commentaire seul. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
337 lines
16 KiB
C++
337 lines
16 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
#pragma once
|
|
|
|
#include <QByteArray>
|
|
#include <QElapsedTimer>
|
|
#include <QObject>
|
|
#include <QPointer>
|
|
#include <QString>
|
|
#include <QStringList>
|
|
#include <QVariantMap>
|
|
|
|
class QIODevice;
|
|
class QJsonObject;
|
|
class QTimer;
|
|
|
|
/*!
|
|
* \brief Adresse d'un optimiseur : \c unix:///chemin ou \c tcp://hôte:port (OPTIMIZER_PROTOCOL §2).
|
|
*
|
|
* \invariant Une adresse non reconnue est \c Invalid, jamais « à peu près » interprétée : un
|
|
* client construit dessus ne se connecte pas.
|
|
*/
|
|
struct OptimizerEndpoint
|
|
{
|
|
/*! \brief Famille de transport. */
|
|
enum Kind { Invalid, Unix, Tcp };
|
|
|
|
Kind kind = Invalid; //!< Famille ; \c Invalid si l'adresse n'a pas été reconnue.
|
|
QString path; //!< Chemin du socket local (\c Unix seulement).
|
|
QString host; //!< Hôte (\c Tcp seulement), sans crochets pour une IPv6.
|
|
quint16 port = 0; //!< Port (\c Tcp seulement), 1-65535.
|
|
|
|
/*!
|
|
* \brief Lit une adresse d'optimiseur.
|
|
* \param text \c unix:///run/powersync/optimizer.sock ou \c tcp://192.168.1.10:7400
|
|
* (IPv6 entre crochets : \c tcp://[fd00::1]:7400).
|
|
* \return L'adresse, ou \c kind == Invalid : schéma inconnu, chemin non absolu, hôte vide,
|
|
* port absent, nul ou hors bornes, ou tout reste après le port.
|
|
*/
|
|
static OptimizerEndpoint parse(const QString &text);
|
|
|
|
/*! \brief \return Vrai si l'adresse est utilisable. */
|
|
bool isValid() const { return kind != Invalid; }
|
|
|
|
/*! \brief \return La forme textuelle canonique, pour le journal (aucun secret n'y figure). */
|
|
QString toString() const;
|
|
};
|
|
|
|
/*!
|
|
* \brief Source de temps et réveil du client, injectable (\c tools/check-simulation-clock.py).
|
|
*
|
|
* Le client ne lit JAMAIS l'horloge murale : ses trois délais (handshake 5 s, \c GetPlan 10 s,
|
|
* backoff 1 → 60 s) sont des durées, mesurées sur cette horloge MONOTONE. Il n'arme qu'un réveil
|
|
* à la fois — l'échéance la plus proche — et réévalue toutes ses échéances quand il sonne.
|
|
*
|
|
* L'implémentation par défaut lit un \c QElapsedTimer et arme un \c QTimer à coup unique. Une
|
|
* suite de test la dérive pour avancer le temps à la main : aucun délai ne s'écoule alors tant
|
|
* que le test ne l'a pas décidé.
|
|
*
|
|
* \invariant \c nowMs() ne décroît jamais.
|
|
* \invariant Après \c armWakeUp(d), \c wakeUp() est émis au plus une fois, au plus tôt \c d ms
|
|
* plus tard ; un nouvel \c armWakeUp() ou \c disarmWakeUp() remplace le réveil en attente.
|
|
*/
|
|
class OptimizerClientClock : public QObject
|
|
{
|
|
Q_OBJECT
|
|
public:
|
|
/*! \brief Horloge réelle (monotone). \param parent Propriétaire Qt. */
|
|
explicit OptimizerClientClock(QObject *parent = nullptr);
|
|
|
|
/*! \brief \return Millisecondes monotones depuis une origine arbitraire. */
|
|
virtual qint64 nowMs() const;
|
|
|
|
/*! \brief Arme le réveil unique. \param delayMs Délai, en ms (0 = au prochain tour de boucle). */
|
|
virtual void armWakeUp(qint64 delayMs);
|
|
|
|
/*! \brief Désarme le réveil en attente, s'il y en a un. */
|
|
virtual void disarmWakeUp();
|
|
|
|
signals:
|
|
/*! \brief Le délai armé est écoulé. */
|
|
void wakeUp();
|
|
|
|
private:
|
|
QElapsedTimer m_elapsed;
|
|
QTimer *m_timer = nullptr;
|
|
};
|
|
|
|
/*!
|
|
* \brief Transport client vers un optimiseur externe (OPTIMIZER_PROTOCOL §2-§4) — brief 4 T2.
|
|
*
|
|
* Un socket \c unix:// (\c QLocalSocket) ou \c tcp:// (\c QTcpSocket), JSON-RPC 2.0, un message
|
|
* par ligne. Le client se connecte, fait le handshake §3, puis porte des \c GetPlan à la demande.
|
|
* Il ne sait rien du contenu d'un plan : il rend le \c result tel quel, la validation est à
|
|
* l'appelant (T3).
|
|
*
|
|
* **Machine d'états** :
|
|
* \code
|
|
* Stopped ──start()──► Connecting ──connecté──► Handshaking ──réponse valide──► Ready
|
|
* │ │ │
|
|
* └──── échec, 5 s ──────────┴── refus, majeure, 5 s ───┤ fermeture
|
|
* ▼ ▼
|
|
* Waiting (backoff 1, 2, 4 … 60 s) ──échéance──► Connecting
|
|
* \endcode
|
|
* Les 5 s du handshake courent depuis le DÉBUT de la tentative : une connexion TCP qui ne
|
|
* s'établit jamais (hôte filtré) est bornée par le même délai. Le backoff double à chaque échec
|
|
* et revient à 1 s après un handshake réussi.
|
|
*
|
|
* **Une requête en vol au plus.** \c requestPlan() pendant qu'une requête attend la remplace :
|
|
* l'ancienne n'est plus attendue, sa réponse sera ignorée à son arrivée (comptée dans
|
|
* \c staleResponses()), et elle ne produit ni \c planReceived() ni \c planFailed(). Les
|
|
* identifiants croissent sur toute la vie du client, à travers les reconnexions.
|
|
*
|
|
* **Erreurs de ligne** — ligne non JSON, message sans identifiant, identifiant jamais émis, ligne
|
|
* de plus de \c kMaxLineBytes : comptées (\c protocolErrors()), annoncées par
|
|
* \c protocolError(), et la connexion est CONSERVÉE. La requête en vol continue d'attendre.
|
|
*
|
|
* **Le token** est relu dans \c tokenPath à CHAQUE tentative de connexion : le remplacer prend
|
|
* effet à la suivante, sans redémarrage. Il ne figure dans aucune ligne de journal, ni en clair
|
|
* ni par sa longueur ; le message de handshake qui le porte n'est jamais journalisé.
|
|
* **Un optimiseur qui le recopie** (\c reason, \c planId, \c name…) ne le fait pas sortir : chaque
|
|
* message reçu est masqué dès son analyse (\c OptimizerSecret::masquerJson(), avec le token
|
|
* envoyé sur CETTE connexion), avant toute journalisation et avant tout signal — journal, traces
|
|
* et charges utiles de l'app ne voient que \c "[token masqué]". Le fait est dit une fois par
|
|
* client, jamais la valeur.
|
|
*
|
|
* \invariant Aucune méthode n'attend : toute E/S est asynchrone. \c requestPlan() rend la main
|
|
* immédiatement ; la réponse arrive par signal, au plus tôt au tour de boucle suivant.
|
|
* \invariant Le client n'écoute aucun port (invariant 10) : il se connecte, c'est tout.
|
|
* \invariant Sans \c start(), le client n'ouvre rien, n'arme rien et ne journalise rien.
|
|
*/
|
|
class OptimizerClient : public QObject
|
|
{
|
|
Q_OBJECT
|
|
public:
|
|
/*! \brief État de la connexion — voir la machine d'états de la classe. */
|
|
enum State {
|
|
Stopped, //!< Pas démarré, ou arrêté par \c stop().
|
|
Waiting, //!< Déconnecté, prochaine tentative au backoff.
|
|
Connecting, //!< Connexion du socket en cours.
|
|
Handshaking, //!< Connecté, \c Handshake envoyé, réponse attendue.
|
|
Ready //!< Handshake accepté : \c requestPlan() est servi.
|
|
};
|
|
Q_ENUM(State)
|
|
|
|
/*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */
|
|
static constexpr const char *kProtocolVersion = "1.1";
|
|
static constexpr qint64 kHandshakeTimeoutMs = 5000; //!< §3, depuis le début de la tentative.
|
|
static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan.
|
|
static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion.
|
|
static constexpr qint64 kBackoffMaxMs = 60000; //!< §2, plafond du backoff.
|
|
/*! \brief Plus longue ligne acceptée, octets hors \c '\\n' (1 Mio, proposition T0 ; écart E24). */
|
|
static constexpr int kMaxLineBytes = 1 << 20;
|
|
|
|
/*! \brief Nom du fichier de token, sous \c NymeaSettings::settingsPath() (décision Q2). */
|
|
static constexpr const char *kTokenFileName = "energy-optimizer.token";
|
|
|
|
/*!
|
|
* \brief Longueur minimale d'un token non vide, en caractères.
|
|
*
|
|
* Le masquage à la réception (\c OptimizerSecret) remplace TOUTE occurrence du token dans ce
|
|
* que l'optimiseur renvoie : un token court, présent par hasard dans un identifiant de charge
|
|
* ou un code, corromprait le plan reçu (\c unknown-load → repli). 16 caractères rendent la
|
|
* rencontre fortuite négligeable.
|
|
*/
|
|
static constexpr int kMinTokenChars = 16;
|
|
|
|
/*! \brief Longueur d'un token, comparée à \c kMinTokenChars.
|
|
* \param token Le token, en UTF-8. \return Son nombre de caractères (points de code). */
|
|
static int tokenLength(const QByteArray &token);
|
|
|
|
/*!
|
|
* \brief Construit un client ARRÊTÉ : rien n'est ouvert avant \c start().
|
|
* \param endpoint Adresse de l'optimiseur ; invalide → \c start() refuse.
|
|
* \param tokenPath Fichier du token (\c defaultTokenPath() en production) ; vide = jamais de
|
|
* token. Un fichier absent vaut « pas de token configuré ».
|
|
* \param engineVersion Version du moteur, annoncée dans \c params.engine.
|
|
* \param clock Horloge injectable ; \c nullptr = horloge réelle, possédée par le client. Une
|
|
* horloge fournie n'est pas possédée et doit survivre au client.
|
|
* \param parent Propriétaire Qt.
|
|
*/
|
|
OptimizerClient(const OptimizerEndpoint &endpoint, const QString &tokenPath,
|
|
const QString &engineVersion, OptimizerClientClock *clock = nullptr,
|
|
QObject *parent = nullptr);
|
|
~OptimizerClient() override;
|
|
|
|
/*! \brief \return \c NymeaSettings::settingsPath() + \c "/energy-optimizer.token". */
|
|
static QString defaultTokenPath();
|
|
|
|
/*!
|
|
* \brief Pose le token d'optimiseur : écriture atomique (\c QSaveFile), droits \c 0600.
|
|
*
|
|
* Les droits sont posés sur le fichier temporaire AVANT d'y écrire le secret : il n'existe
|
|
* aucun instant où le token est sur disque avec les droits de l'umask. Un fichier préexistant
|
|
* aux droits plus larges est ainsi resserré à 0600.
|
|
*
|
|
* \param path Fichier cible (\c defaultTokenPath() en production). Jamais \c energy.conf.
|
|
* \param token Secret ; vide = retrait du fichier (plus de token configuré). Refusé s'il
|
|
* compte moins de \c kMinTokenChars caractères, plus de 4 096 octets, un blanc ou un
|
|
* caractère de contrôle — et rien n'est alors modifié.
|
|
* \param[out] error Motif d'échec, en français, sans le secret.
|
|
* \return Vrai si le fichier est posé (ou retiré) avec succès.
|
|
* \note Pas de RPC dans ce lot : c'est la fonction qu'elle appellera.
|
|
*/
|
|
static bool writeToken(const QString &path, const QByteArray &token, QString *error = nullptr);
|
|
|
|
/*! \brief Démarre : première tentative immédiate. Sans effet si déjà démarré. */
|
|
void start();
|
|
|
|
/*! \brief Ferme la connexion et désarme tout. La requête en vol est abandonnée sans signal. */
|
|
void stop();
|
|
|
|
/*! \brief \return L'état courant. */
|
|
State state() const { return m_state; }
|
|
|
|
/*!
|
|
* \brief Envoie un \c GetPlan. Remplace la requête en vol s'il y en a une.
|
|
* \param params Les \c params de la requête, tels quels (\c SurplusContext sérialisé, T1).
|
|
* \return L'identifiant JSON-RPC de la requête, ou 0 si le client n'est pas \c Ready (rien
|
|
* n'est envoyé : l'appelant reste sur sa stratégie de repli).
|
|
*/
|
|
quint64 requestPlan(const QVariantMap ¶ms);
|
|
|
|
/*! \brief \return Identifiant de la requête attendue, 0 si aucune. */
|
|
quint64 pendingRequestId() const { return m_pendingId; }
|
|
|
|
/*! \brief \return \c result.capabilities du dernier handshake accepté (vide si absent). */
|
|
QVariantMap capabilities() const { return m_capabilities; }
|
|
|
|
/*! \brief \return Vrai si le dernier handshake accepté portait \c capabilities (§3 : seul
|
|
* ce cas restreint les \c kind acceptés). */
|
|
bool hasCapabilities() const { return m_hasCapabilities; }
|
|
|
|
/*! \brief \return \c result.name du dernier handshake accepté. */
|
|
QString serverName() const { return m_serverName; }
|
|
|
|
/*! \brief \return \c result.protocolVersion du dernier handshake accepté. */
|
|
QString serverProtocolVersion() const { return m_serverProtocolVersion; }
|
|
|
|
/*! \brief \return \c result.planning du dernier handshake accepté. */
|
|
bool serverPlanning() const { return m_serverPlanning; }
|
|
|
|
/*! \brief \return Erreurs de ligne comptées depuis la construction (connexion conservée). */
|
|
int protocolErrors() const { return m_protocolErrors; }
|
|
|
|
/*! \brief \return Réponses reçues pour une requête remplacée ou expirée, ignorées. */
|
|
int staleResponses() const { return m_staleResponses; }
|
|
|
|
/*! \brief \return Code de la dernière erreur (transport, handshake, ligne ou requête). */
|
|
QString lastErrorCode() const { return m_lastErrorCode; }
|
|
|
|
/*! \brief \return Délai qui sera appliqué au prochain échec de connexion, en ms. */
|
|
qint64 nextBackoffMs() const { return m_backoffMs; }
|
|
|
|
signals:
|
|
/*! \brief L'état a changé. \param state Le nouvel état. */
|
|
void stateChanged(OptimizerClient::State state);
|
|
|
|
/*! \brief Handshake accepté : \c capabilities() & co. sont à jour. */
|
|
void ready();
|
|
|
|
/*! \brief Réponse à la requête attendue. \param id Son identifiant. \param result Le
|
|
* \c result JSON-RPC, non validé. */
|
|
void planReceived(quint64 id, const QVariantMap &result);
|
|
|
|
/*!
|
|
* \brief La requête attendue n'aura pas de réponse utilisable.
|
|
* \param id Son identifiant.
|
|
* \param code \c timeout (10 s), \c disconnected (connexion perdue), \c rpc-error (réponse
|
|
* \c error), \c invalid-response (ni \c result objet ni \c error).
|
|
* \note Une requête REMPLACÉE n'émet rien : son abandon est le fait de l'appelant.
|
|
*/
|
|
void planFailed(quint64 id, const QString &code);
|
|
|
|
/*! \brief Une ligne reçue a été écartée, la connexion est conservée.
|
|
* \param code \c line-too-long, \c invalid-json, \c missing-id, \c unknown-id. */
|
|
void protocolError(const QString &code);
|
|
|
|
/*! \brief Notification JSON-RPC (\c method sans \c id), ex. \c UpdateLearned (§8). Rien
|
|
* n'en est fait ici. \param method Son nom. \param params Ses paramètres. */
|
|
void notificationReceived(const QString &method, const QVariantMap ¶ms);
|
|
|
|
private:
|
|
void setState(State state);
|
|
void connectNow();
|
|
void onConnected();
|
|
void onSocketLost();
|
|
void onReadyRead();
|
|
void onWakeUp();
|
|
void handleLine(const QByteArray &line);
|
|
void handleHandshakeReply(const QJsonObject &message);
|
|
void handlePlanReply(quint64 id, const QJsonObject &message);
|
|
void failConnection(const QString &code, const QString &detail);
|
|
void dropSocket();
|
|
void scheduleReconnect();
|
|
void rearm();
|
|
void countProtocolError(const QString &code, const QString &detail);
|
|
bool send(const QVariantMap &message);
|
|
QByteArray readToken();
|
|
|
|
OptimizerEndpoint m_endpoint;
|
|
QString m_tokenPath;
|
|
QString m_engineVersion;
|
|
OptimizerClientClock *m_clock = nullptr;
|
|
|
|
State m_state = Stopped;
|
|
QPointer<QIODevice> m_socket;
|
|
QByteArray m_buffer;
|
|
bool m_skippingLine = false; // reste d'une ligne trop longue à jeter jusqu'au '\n'
|
|
|
|
quint64 m_lastId = 0; // dernier identifiant émis
|
|
quint64 m_handshakeId = 0;
|
|
quint64 m_pendingId = 0;
|
|
bool m_tokenSent = false; // le handshake en cours porte un token (pour le diagnostic)
|
|
QString m_tokenConnexion; // le token envoyé sur CETTE connexion : ce qu'un écho recopierait
|
|
QByteArray m_tokenTentative; // relu au début de la tentative, envoyé à son handshake
|
|
bool m_echoTokenSignale = false;
|
|
|
|
qint64 m_handshakeDeadline = -1; // -1 = pas d'échéance
|
|
qint64 m_requestDeadline = -1;
|
|
qint64 m_reconnectAt = -1;
|
|
qint64 m_backoffMs = kBackoffInitialMs;
|
|
|
|
QVariantMap m_capabilities;
|
|
bool m_hasCapabilities = false;
|
|
QString m_serverName;
|
|
QString m_serverProtocolVersion;
|
|
bool m_serverPlanning = false;
|
|
|
|
int m_protocolErrors = 0;
|
|
int m_staleResponses = 0;
|
|
QString m_lastErrorCode;
|
|
QString m_failureStreakCode; // échec répété : annoncé une fois, puis compté
|
|
int m_failureStreak = 0;
|
|
QStringList m_errorsLoggedThisConnection;
|
|
};
|