Patrick Schurig 53a10b3062 SetLoadConfig : motif de refus publié (LOAD_CONFIG_REFUSED), id et échéance bornés à l'écriture (Q7)
Le refus garde EnergyErrorInvalidParameter et gagne, comme SetTariff, un
`fault` LOAD_CONFIG_REFUSED et des `faultParams` : une cause par motif
existant (ID, LABEL, DEADLINE, MODE, DOMAIN, RANK_ORIGIN, RANK_ORIGIN_AUTO,
ADAPTER, FIELD_*, SG_READY_STATE_2, ID_DUPLICATE, THING_SHARED,
RANK_DUPLICATE, WRITE_FAILED, STORE_UNAVAILABLE) et l'endroit (index, id
quand il est bien formé, field, state, indexes/ids, rank, length/max,
position). Jamais le libellé. Les deux champs sont déclarés optionnels dans
le schéma ; un succès rend exactement ce qu'il rendait.

- id : 1 à 128 caractères de [A-Za-z0-9._{}-], refusé à l'écriture ;
  hérité hors règle, chargé tel quel et signalé.
- needs.dailyDeadline : vide ou HH:MM (00:00-23:59), refusé à l'écriture ;
  hérité illisible, l'entrée est chargée, l'échéance retirée de la mémoire
  (ni l'arbitre ni l'optimiseur ne la reçoivent) et signalée.
- Les bornes de saisie passent avant la réconciliation de rankOrigin, dont le
  motif cite le libellé.
- appendConfig() : l'ajout par le moteur ne borne que l'entrée ajoutée.
- leftChars() coupe en caractères sans couper une paire de substitution ;
  l'id d'un motif n'est plus coupé par QString::left().
- Au chargement, chaque libellé trop long est signalé, pas seulement le
  premier ; loadconfigstore.h dit « n'écarte rien au chargement ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:20:07 +02:00

255 lines
14 KiB
C++

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