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>
255 lines
14 KiB
C++
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;
|
|
};
|