// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include "../types/loadconfig.h" /*! * \brief Store persistant des configs de charge pilotée (contrat etmvariableload §4). * * Master store unique : \c setConfigs() valide, **persiste** (\c /var/lib/nymea/\c * energy-load-configuration.json, à côté de \c energy-manager-configuration.json) et émet * \c changed() — l'arbitre reconstruit ses adaptateurs sur ce signal, le handler notifie l'app. * * Format fichier : \c {"version":1,"loads":[ ... ]}, chaque entrée étant un \c LoadConfig §4. * Le tableau \c loads[] est **mot pour mot** le \c LoadDescriptor §4 (jonction inter-repos * avec l'app). * * \invariant Écriture atomique (fichier temporaire + \c rename) — jamais de fichier mi-écrit. * \invariant \c setConfigs() rejette en bloc (rien persisté) si une seule entrée est invalide. */ class LoadConfigStore : public QObject { Q_OBJECT public: /*! * \brief Construit le store et charge le fichier s'il existe. * \param parent Propriétaire Qt. * \note Un fichier absent, illisible ou au JSON invalide n'est pas une erreur : * le store démarre à vide. Une ENTRÉE invalide est ignorée individuellement * (tolérance ascendante) — on ne perd pas les autres pour une seule. */ explicit LoadConfigStore(QObject *parent = nullptr); /*! \brief Configs actuellement en mémoire (chargées au démarrage ou via setConfigs). * \return Copie de l'ensemble courant ; toutes ont passé \c LoadConfig::isValid(). */ LoadConfigs configs() const { return m_configs; } /*! * \brief Remplace l'ensemble des configs : valide tout → persiste → \c emit changed(). * \param configs Nouvel ensemble (remplace l'existant — l'app envoie la liste complète). * \param[out] error Message FR si rejet (aucune écriture dans ce cas). * \param[out] fault Si rejet, les \c faultParams du motif \c LOAD_CONFIG_REFUSED : \c cause * et l'endroit (\c index, \c id, \c field, …) — liste des causes : \c DecisionCode::LoadConfigRefused. * \return true si validé + persisté ; false si invalide ou échec d'écriture. */ bool setConfigs(const LoadConfigs &configs, QString *error = nullptr, QVariantMap *fault = nullptr); /*! * \brief AJOUT d'une entrée par le MOTEUR (auto-provisionnement d'une borne, LM-1209). * * Mêmes contrôles que \c setConfigs(), à une différence près : les bornes de saisie * (\c validateBounds() — identifiant, libellé, échéance) ne portent que sur l'entrée AJOUTÉE. * Un client renvoie l'ensemble entier : il l'apporte, il en répond, et une entrée héritée hors * règle bloque son écriture jusqu'à ce qu'il la répare. Le moteur n'apporte qu'une entrée ; il * ne sait pas réparer les autres (raccourcir un libellé humain n'est pas son geste), et en être * bloqué laisserait la borne arbitrée sans rang réglable — le trou refermé en 3g-2. * * \param config Entrée à ajouter ; son \c id ne doit pas déjà figurer dans l'ensemble. * \param[out] error Message FR si rejet (aucune écriture dans ce cas). * \param[out] fault Comme pour \c setConfigs(). * \return true si validé + persisté. * * \note La cohérence d'ensemble (\c validateSet()) et l'unicité des rangs (\c validateRanks()) * restent contrôlées sur l'ensemble complet : la première protège un organe de deux * commandeurs, et un rang en double hérité bloque encore l'ajout, comme avant ce lot. */ bool appendConfig(const LoadConfig &config, QString *error = nullptr, QVariantMap *fault = nullptr); /*! \brief Cohérence de l'ENSEMBLE, ce qu'aucune charge ne peut vérifier seule. * * ECS-110 s'arrête au bord d'une charge : \c LoadConfig::isValid() ne voit pas les * autres. Deux exigences ne vivent donc que ici — identifiants uniques, et un Thing * revendiqué par au plus UNE charge active. Le second cas est le même défaut que la * garde de l'arbitre contre la PAC codée en dur, mais entre deux charges de * configuration : deux commandes contradictoires sur un organe, et sa puissance * comptée deux fois dans le budget (règle absolue 1). * * \note Seules les charges \c enabled sont confrontées. Une charge désactivée ne * construit aucun adaptateur et ne commande rien ; l'interdire empêcherait de * préparer une configuration de remplacement. La bascule \c enabled repasse par * \c setConfigs(), donc par cette vérification. * * \param configs Ensemble complet à confronter (les charges désactivées sont ignorées). * \param[out] error Motif de refus, en français, exploitable tel quel. * \param[out] fault Si refus : \c ID_DUPLICATE (\c index, \c id) ou \c THING_SHARED * (\c indexes, \c ids — la paire de charges). * \return true si l'ensemble est cohérent ; false au PREMIER conflit rencontré. */ static bool validateSet(const LoadConfigs &configs, QString *error = nullptr, QVariantMap *fault = nullptr); /*! * \brief Contrôle de BONNE FORME, appliqué aux ÉCRITURES et à elles seules. §12 / LM-1209-a. * * \param configs Ensemble candidat. \param error Motif du refus, si refus. * \param[out] fault Si refus : \c RANK_DUPLICATE (\c indexes, \c ids, \c rank). * \return Faux si deux charges portent le même rang. * * \note **Distincte de \c validateSet(), et la distinction est de sûreté, pas de propreté** * — \rule{LM-1209-a-bis} : une vérification qui ÉCARTE et une qui REFUSE ne peuvent pas * être la même fonction. * \c validateSet() nomme ce qui rend un ensemble DANGEREUX — deux charges sur un même * organe — et \c load() s'en sert pour ÉCARTER l'entrée fautive. Celle-ci nomme ce qui * rend un ensemble MAL FORMÉ, et le seul geste correct y est de REFUSER l'écriture * entière. Les fusionner ferait écarter au démarrage les entrées d'une configuration * héritée à rangs doublés : des charges disparaîtraient de la configuration ET de * l'arbitrage, sans \c applySafeState — une règle de contrat convertie en perte de * configuration silencieuse. * \note \c load() n'écarte donc rien sur sa foi. Un doublon hérité est **signalé * bruyamment et CONSERVÉ** ; la première écriture cliente le répare. */ static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr, QVariantMap *fault = nullptr); //! Plus long libellé de charge accepté à l'écriture, en caractères (points de code Unicode). static constexpr int MaxLabelChars = 256; /*! * \brief Borne la longueur des libellés, à l'ÉCRITURE seulement (protocole optimiseur 1.2, Q7). * * Le libellé part tel quel dans chaque \c GetPlan (§5.3). Sans borne, une saisie démesurée * ferait dépasser la limite de 1 Mio par ligne du protocole, et l'optimiseur ne recevrait plus * rien. Le client d'optimiseur refuse d'émettre une telle ligne (\c request-too-long) : cette * borne-ci arrête la saisie avant qu'elle n'y arrive. * * \param configs Ensemble candidat. \param[out] error Motif du refus, en français ; il nomme * la charge par son identifiant et donne la longueur, jamais le libellé. * \param[out] fault Si refus : \c LABEL (\c index, \c id, \c length, \c max). * \param from Premier index contrôlé (les précédents ne le sont pas) ; voir \c appendConfig(). * \return Faux si un libellé compte plus de \c MaxLabelChars caractères. * * \note Même famille que \c validateRanks() (\rule{LM-1209-a-bis}) : un refus d'écriture, jamais * un écart au chargement. \c load() n'écarte rien sur sa foi — un libellé plus long déjà * persisté est **chargé tel quel, signalé, jamais tronqué** ; la première écriture cliente * devra le raccourcir. */ static bool validateLabels(const LoadConfigs &configs, QString *error = nullptr, QVariantMap *fault = nullptr, int from = 0); //! Plus long identifiant de charge accepté à l'écriture, en caractères. static constexpr int MaxIdChars = 128; /*! * \brief \return Vrai si \p id est un identifiant de charge bien formé : 1 à \c MaxIdChars * caractères, tous dans \c [A-Za-z0-9._{}-]. * \param id Identifiant à juger. * * L'identifiant est une CLÉ, pas un texte : il part dans chaque \c GetPlan (\c loads[].id, et * clé des actions), il est recopié dans les motifs et le journal. Le jeu couvre les deux formes * réelles — un ThingId entre accolades (38 caractères) et un nom court à tirets. */ static bool idIsWellFormed(const QString &id); /*! * \brief Borne les identifiants, à l'ÉCRITURE seulement (Q7). * \param configs Ensemble candidat. * \param[out] error Motif du refus, en français ; il donne la position de l'entrée et la * longueur, jamais l'identifiant refusé. * \param[out] fault Si refus : \c ID (\c index, \c length, \c max, et \c position, premier * caractère hors jeu, s'il y en a un). Jamais \c id : c'est lui qui est refusé. * \param from Premier index contrôlé ; voir \c appendConfig(). * \return Faux si un identifiant n'est pas \c idIsWellFormed(). * * \note Même partage que \c validateLabels() : un identifiant hors règle déjà persisté est * **chargé tel quel et signalé** — le changer reviendrait à retirer une charge et en créer * une autre, ce qu'une lecture n'a pas à décider. */ static bool validateIds(const LoadConfigs &configs, QString *error = nullptr, QVariantMap *fault = nullptr, int from = 0); /*! * \brief \return Vrai si \p deadline est vide ou de la forme \c HH:MM, de 00:00 à 23:59, deux * chiffres de chaque côté. \param deadline Valeur de \c needs.dailyDeadline. */ static bool deadlineIsWellFormed(const QString &deadline); /*! * \brief Contrôle l'échéance quotidienne (\c needs.dailyDeadline), à l'ÉCRITURE seulement. * \param configs Ensemble candidat. * \param[out] error Motif du refus, en français ; la valeur refusée n'y est citée que tronquée. * \param[out] fault Si refus : \c DEADLINE (\c index, \c id). * \param from Premier index contrôlé ; voir \c appendConfig(). * \return Faux si une échéance n'est pas \c deadlineIsWellFormed(). * * \note Au chargement, l'entrée est conservée mais l'échéance illisible est **retirée de la * mémoire** (le fichier n'est pas réécrit) : elle n'atteint ni l'arbitre ni l'optimiseur. * Sans échéance, une obligation en énergie n'a pas de cadence et rien n'est acheté pour * elle — le côté sûr pour un mécanisme qui achète. Une échéance illisible n'était de toute * façon résolue en aucun instant ; elle partait seulement, brute, dans \c GetPlan. */ static bool validateDeadlines(const LoadConfigs &configs, QString *error = nullptr, QVariantMap *fault = nullptr, int from = 0); /*! * \brief Les bornes de SAISIE, dans l'ordre : identifiants, libellés, échéances (Q7). * * Avant tout autre contrôle, et avant \c reconcileRankOrigin() : les motifs suivants citent * l'identifiant et le libellé, qui doivent donc déjà être bornés pour entrer au journal. * * \param configs Ensemble candidat. \param[out] error Motif du refus, en français. * \param[out] fault Si refus, le motif de \c validateIds(), \c validateLabels() ou * \c validateDeadlines(). \param from Premier index contrôlé ; voir \c appendConfig(). * \return Faux au premier manquement. */ static bool validateBounds(const LoadConfigs &configs, QString *error = nullptr, QVariantMap *fault = nullptr, int from = 0); /*! * \brief \return Les \p n premiers caractères de \p texte — en points de code, jamais au milieu * d'une paire de substitution UTF-16 (à l'inverse de \c QString::left()). * \param texte Texte à couper. \param n Nombre de caractères gardés. */ static QString leftChars(const QString &texte, int n); /*! * \brief Réconcilie \c rankOrigin d'une écriture CLIENTE avec l'état persisté. LM-1209-c. * * \param incoming Ensemble reçu du client. \param error Motif du refus, si refus. * \param[out] out Ensemble à persister, \c rankOrigin réconcilié. * \param[out] fault Si refus : \c RANK_ORIGIN_AUTO (\c index, \c id). * \return Faux si le client tente de FABRIQUER un \c "auto". * * Trois règles, et la deuxième est celle qu'on se serait trompé à écrire : * 1. Rang CHANGÉ pour cet id → la marque est **effacée** : quelqu'un a classé la charge. * 2. Rang INCHANGÉ → la marque est **conservée telle quelle**, même si le client l'omet. * `SetLoadConfig` remplace en bloc : réordonner une AUTRE charge renvoie forcément * l'entrée de celle-ci, et l'effacer à cette occasion perdrait une marque encore * vraie — la charge n'a toujours été classée par personne. **L'effacement porte sur le * RANG, jamais sur la réception d'une écriture.** * 3. \c "auto" reçu là où le persisté ne l'a pas → **refus**. Un client ne fabrique pas * une affirmation sur un geste du moteur. L'aller-retour verbatim d'une entrée qui la * porte déjà reste licite, sans quoi la neutralité Get→Set serait rompue. * * \note Un client lève la marque SANS déplacer la charge en envoyant \c "user" — c'est la * seule voie, et elle est explicite. L'omission ne lève rien (règle 2). */ bool reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out, QString *error = nullptr, QVariantMap *fault = nullptr) const; signals: /*! \brief Émis après une persistance réussie de \c setConfigs(). */ void changed(); private: QString filePath() const; bool load(); bool save(QString *error) const; // Valide puis persiste ; les bornes de saisie ne portent qu'à partir de l'index premierBorne. bool commit(const LoadConfigs &configs, int premierBorne, QString *error, QVariantMap *fault); LoadConfigs m_configs; };