Patrick Schurig 70c1795322 feat(§12): LM-1209 — insertion en queue, rang unique, et la création se dit
Les trois conditions, d'un bloc.

INSERTION EN QUEUE. provisionEvChargerConfig() posait rang min − 1, écrasé à 1 —
donc à égalité avec la charge que l'installateur avait posée là exprès. C'est
désormais 1 + max(rangs). Queue de LISTE et non de domaine : les domaines ne sont
pas contigus, donc « juste après le dernier ev » exigerait de pousser une charge
d'un autre domaine, ce que la condition 2 interdit. Et comme max(domaine) ≤
max(liste), la queue de liste est toujours au moins aussi reculée — elle coïncide
quand le domaine occupe la fin, et sinon place la nouvelle charge derrière tout
le monde plutôt que de l'intercaler devant une charge classée. Plus fidèle au
principe que la formulation d'origine.

UNICITÉ. validateRanks() refuse une écriture où deux charges portent le même
rang. Avec la mise en garde écrite noir sur blanc : l'égalité ne coûte pas le
déterminisme — le tri est total sur (rang, identifiant) depuis 3g-2 — elle coûte
la gouvernabilité. S'y tromper ferait chercher le défaut dans le tri.

ET LA RÈGLE GÉNÉRALE QUI EN SORT (LM-1209-a-bis) : une vérification qui ÉCARTE et
une vérification qui REFUSE ne peuvent pas être la même fonction. validateSet()
nomme ce qui rend l'ensemble dangereux et load() s'en sert pour écarter ; y
ajouter l'unicité de rang aurait fait perdre des charges au démarrage sur une
configuration héritée — disparues de la configuration ET de l'arbitrage, sans
applySafeState, soit ECS-413 rouvert par une autre porte. Les deux sont
séparées ; validateRanks() n'est appelée que par setConfigs(). Au chargement, un
doublon hérité est signalé bruyamment et CONSERVÉ.

o:rankOrigin. Énumération fermée "auto" | "user", absente par défaut. Une
énumération et non un booléen parce que le troisième état est le point :
`rankIsDefault: false` affirmerait que le rang a été choisi, alors que c'est ce
qu'on ignore d'une configuration héritée. L'absence doit porter le « on ne sait
pas » ; un booléen ne sait pas se taire. Même motif que measurement.source, et la
spec le nomme désormais comme un motif de conception plutôt qu'une décision au
cas par cas.

Et la précision demandée en écrivant : l'effacement porte sur le RANG, jamais sur
la réception d'une écriture. SetLoadConfig remplace en bloc, donc réordonner une
AUTRE charge renvoie forcément l'entrée de celle-ci — l'effacer à cette occasion
perdrait une marque encore vraie. L'omission ne lève rien ; pour lever sans
déplacer, un client envoie "user". Un "auto" fabriqué est refusé, l'aller-retour
verbatim reste licite.

Trois tests. testAutoProvisionedRankGoesToTheQueue vérifié échouant avec
l'insertion en tête — et il échoue en disant que la borne n'entre PAS en
configuration, les deux mécanismes se répondant : le rang libre par construction
et le refus d'écriture en filet. testRankOriginFollowsTheRankNotTheWrite couvre
le cas qui se serait perdu, un client naïf qui omet la clé en déplaçant une autre
charge. testRankUniquenessRefusesWriteButNeverDiscards couvre la moitié qui ne se
voit pas : qu'un chargement conserve ce qu'il aurait pu écarter.

amd64 0 erreur / 0 avertissement. simulation 31/31, charging 16/16, loadmodel
20/20, spotmarket 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 15:56:46 +02:00

122 lines
6.4 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).
* \return true si validé + persisté ; false si invalide ou échec d'écriture.
*/
bool setConfigs(const LoadConfigs &configs, QString *error = 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.
* \return true si l'ensemble est cohérent ; false au PREMIER conflit rencontré.
*/
static bool validateSet(const LoadConfigs &configs, QString *error = 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.
* \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é.**
* \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 N'est donc JAMAIS appelée par \c load(). 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);
/*!
* \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é.
* \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) 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;
LoadConfigs m_configs;
};