// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include #include #include #include #include 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 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é). * \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 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 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; };