Patrick Schurig e37f204047 fix(config): ECS-110-b — un Thing n'appartient qu'à UNE charge active
Le cas général n'était pas couvert, et le trou était complet. isValid() s'arrête au bord
d'une charge : deux charges de configuration pouvaient revendiquer le même Thing — un
relais déclaré à la fois dans la charge ECS et dans un état SG-Ready — avec exactement
les conséquences que la garde de l'arbitre évite pour la PAC codée en dur : deux
commandes contradictoires sur un organe, sa puissance comptée deux fois dans le budget
(règle absolue 1). Rien ne l'empêchait, rien ne l'aurait signalé.

L'exclusivité est une propriété de l'ENSEMBLE, qu'aucune charge ne peut vérifier seule :
LoadConfigStore::validateSet(), appelée par setConfigs() (rejet total, rien persisté) et
au chargement (l'entrée en conflit est écartée, le reste survit — écarter une charge ne
commande rien, la charger commanderait faux).

Deux doublons intra-charge manquaient aussi : deux étages relay-router sur le même Thing,
que le routeur fusionnait en silence en annonçant le double de la puissance réelle, et un
relais répété dans un même état SG-Ready. Un relais présent dans PLUSIEURS états reste
légitime : l'état 4 est l'union des états 1 et 3.

Portée tranchée : seules les charges enabled sont confrontées — une charge désactivée ne
construit aucun adaptateur, et l'interdire empêcherait de préparer un remplacement ;
activer repasse par setConfigs(). ThingIds normalisés via QUuid, la configuration
mélangeant « {uuid} » et « uuid ».

LM-302-b : tout mécanisme futur doit déclarer ses Things via claimedThingIds(), seul
point à étendre — l'oublier le rendrait invisible à la vérification.

Suite complète : 112 tests, 0 échec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:29:28 +02:00

75 lines
3.3 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.
*/
static bool validateSet(const LoadConfigs &configs, QString *error = nullptr);
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;
};