Patrick Schurig d3dfe519d5 docs(règles): sept règles étaient implémentées et non citées
Premier usage réel de l'index, et il donne un résultat inattendu : sur les 70
règles « spécifiée seule », une part notable n'est PAS un trou d'implémentation
mais un trou de CITATION. Vérifié en cherchant le CONTENU dans le code plutôt que
l'identifiant :

  ECS-110-b      un Thing n'appartient qu'à une charge active  → validateSet()
  LM-302-b       l'exclusivité ne se valide pas dans une charge utile → idem
  LM-1209-a-bis  écarter et refuser ne sont pas la même fonction → validateRanks()
  ECS-410-b      l'issue de ClearLoadFault est journalisée par l'arbitre
  LM-303         les verrous appartiennent au mécanisme, pas au domaine
  LM-1208-c      sans voiture, le nombre de phases vient de la borne
  ECS-308        MaxRelays TRONQUE au lieu de refuser

Toutes portées par du code, aucune citée. L'index les comptait donc comme
spécifiées seules — il SOUS-DÉCLARE la couverture, exactement comme il peut la
surdéclarer. C'est la même limite que celle écrite en tête de la page générée
(« cité par n'est pas couvert par »), prise par l'autre bout : un identifiant
absent d'un commentaire ne dit rien de ce que le code fait.

Citations ajoutées avec \rule{}, l'alias posé hier — donc navigables du
commentaire vers la règle. Aucun changement de comportement : ce sont des
commentaires. 63 « spécifiée seule » au lieu de 70, 21 citées par le code au lieu
de 14.

ci-quality 3/3, build 0/0.

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

252 lines
10 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "loadconfigstore.h"
#include "plugininfo.h"
#include <nymeasettings.h>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonParseError>
LoadConfigStore::LoadConfigStore(QObject *parent)
: QObject{parent}
{
if (load())
qCDebug(dcNymeaEnergy()) << "[LoadConfigStore]" << m_configs.count()
<< "config(s) de charge pilotée chargée(s) depuis" << filePath();
else
qCDebug(dcNymeaEnergy()) << "[LoadConfigStore] Aucune config de charge pilotée — démarrage à vide.";
}
QString LoadConfigStore::filePath() const
{
const QString env = QString(qgetenv("NYMEA_ENERGY_LOAD_CONFIG"));
if (!env.isEmpty())
return env;
return NymeaSettings::storagePath() + "/energy-load-configuration.json";
}
bool LoadConfigStore::load()
{
const QString path = filePath();
if (!QFileInfo::exists(path))
return false;
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Ouverture impossible:" << path << file.errorString();
return false;
}
const QByteArray data = file.readAll();
file.close();
QJsonParseError err;
const QJsonDocument doc = QJsonDocument::fromJson(data, &err);
if (err.error != QJsonParseError::NoError) {
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] JSON invalide:" << err.errorString();
return false;
}
LoadConfigs loaded;
const QJsonArray loads = doc.object().value("loads").toArray();
for (const QJsonValue &v : loads) {
const LoadConfig c = LoadConfig::fromMap(v.toObject().toVariantMap());
QString why;
if (!c.isValid(&why)) {
// Tolérance ascendante : on ignore une entrée corrompue plutôt que de tout perdre.
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Entrée ignorée (invalide):" << why;
continue;
}
// Conflit d'ensemble : on écarte l'entrée fautive (la PREMIÈRE lue garde son Thing)
// plutôt que de rejeter tout le fichier — même tolérance ascendante que ci-dessus.
// Écarter est ici plus sûr que charger : deux adaptateurs sur un organe se
// contrediraient, alors qu'une charge absente ne fait que ne rien commander.
LoadConfigs essai = loaded;
essai.append(c);
QString conflit;
if (!validateSet(essai, &conflit)) {
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Entrée écartée (conflit d'ensemble):" << conflit;
continue;
}
loaded.append(c);
}
// LM-1209-a — les doublons de rang sont SIGNALÉS, jamais écartés. Le contrôle de bonne
// forme (validateRanks) ne s'applique qu'aux écritures : ici, écarter ferait disparaître
// des charges de la configuration ET de l'arbitrage, sans applySafeState, sur la seule foi
// d'une règle de contrat resserrée après coup. La première écriture cliente répare.
{
QString pourquoi;
if (!validateRanks(loaded, &pourquoi))
qCWarning(dcNymeaEnergy())
<< "[LoadConfigStore] CONFIGURATION CONSERVÉE MAIS MAL FORMÉE :" << pourquoi
<< "— l'ordre de service reste reproductible (tri sur rang PUIS identifiant),"
<< "mais il n'est PAS réglable tant que les rangs sont à égalité : il obéit à"
<< "des identifiants, donc à personne. À réparer par un SetLoadConfig.";
}
m_configs = loaded;
return true;
}
bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error)
{
auto fail = [error](const QString &why) {
if (error) *error = why;
return false;
};
QSet<QString> ids;
QHash<QString, QString> proprietaire; // ThingId normalisé → libellé de la charge
for (const LoadConfig &c : configs) {
if (ids.contains(c.id()))
return fail(QStringLiteral("l'identifiant de charge \"%1\" apparaît deux fois").arg(c.id()));
ids.insert(c.id());
if (!c.enabled())
continue; // ne construit aucun adaptateur : ne revendique rien
// \rule{ECS-110-b} — un Thing n'appartient qu'à UNE charge active. \rule{LM-302-b} :
// l'exclusivité ne peut PAS se valider dans une charge utile, aucune charge ne voyant
// les autres — d'où ce contrôle d'ENSEMBLE, et d'où le fait qu'il soit distinct de
// isValid().
for (const QString &thing : c.claimedThingIds()) {
const QString deja = proprietaire.value(thing);
if (!deja.isEmpty())
return fail(QStringLiteral("le Thing %1 est commandé par deux charges actives, "
"\"%2\" et \"%3\" : deux commandes contradictoires et "
"une puissance comptée deux fois")
.arg(thing, deja, c.label()));
proprietaire.insert(thing, c.label());
}
}
return true;
}
bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error)
{
QHash<int, QString> parRang;
for (const LoadConfig &c : configs) {
const QString deja = parRang.value(c.priority());
if (!deja.isEmpty()) {
if (error)
*error = QStringLiteral("les charges \"%1\" et \"%2\" portent toutes deux le rang "
"%3 : leur ordre de service serait stable mais NON "
"RÉGLABLE — il se déciderait sur leurs identifiants")
.arg(deja, c.label()).arg(c.priority());
return false;
}
parRang.insert(c.priority(), c.label());
}
return true;
}
bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
QString *error) const
{
QHash<QString, const LoadConfig *> stocke;
for (const LoadConfig &c : m_configs)
stocke.insert(c.id(), &c);
LoadConfigs resultat;
for (LoadConfig c : incoming) {
const LoadConfig *avant = stocke.value(c.id(), nullptr);
const QString marqueAvant = avant ? avant->rankOrigin() : QString();
// Règle 3 — un client ne FABRIQUE pas un "auto". La seule tolérance est l'aller-retour
// verbatim d'une entrée qui le porte déjà : sans elle, la neutralité Get→Set du
// contrat serait rompue et tout client renvoyant ce qu'il a lu se verrait refusé.
if (c.rankOrigin() == QLatin1String("auto")
&& marqueAvant != QLatin1String("auto")) {
if (error)
*error = QStringLiteral("la charge \"%1\" prétend un rankOrigin \"auto\" que le "
"moteur n'a pas posé : c'est une affirmation sur un geste "
"du moteur, elle ne s'écrit pas depuis un client")
.arg(c.label());
return false;
}
if (avant && c.priority() != avant->priority()) {
// Règle 1 — le rang a CHANGÉ : quelqu'un a classé la charge. La marque tombe, et
// elle tombe vers le VIDE, pas vers "user" : le moteur constate un déplacement, il
// ne sait pas si un humain ou un script l'a voulu. Ne rien affirmer est le
// minimum honnête (LM-1209-c).
c.setRankOrigin(QString());
} else if (avant && c.rankOrigin().isEmpty()) {
// Règle 2 — rang INCHANGÉ et marque omise : on CONSERVE. SetLoadConfig remplace en
// bloc, donc réordonner une AUTRE charge renvoie forcément celle-ci ; l'effacer à
// cette occasion perdrait une marque encore vraie. L'effacement porte sur le RANG,
// jamais sur la réception d'une écriture.
c.setRankOrigin(marqueAvant);
}
// Reste le cas « rang inchangé, marque explicite » : elle est prise telle quelle —
// c'est ainsi qu'un client lève la marque en envoyant "user", sans déplacer la charge.
resultat.append(c);
}
if (out)
*out = resultat;
return true;
}
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error)
{
// Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté).
for (const LoadConfig &c : configs) {
QString why;
if (!c.isValid(&why)) {
if (error) *error = QStringLiteral("Config \"%1\" invalide : %2").arg(c.label(), why);
return false;
}
}
// …puis la cohérence d'ensemble, qu'aucune charge ne peut vérifier seule.
if (!validateSet(configs, error))
return false;
// …puis la BONNE FORME, qui ne s'applique qu'ici. load() ne l'appelle pas : elle refuse
// une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a).
if (!validateRanks(configs, error))
return false;
const LoadConfigs previous = m_configs;
m_configs = configs;
if (!save(error)) {
m_configs = previous; // rollback mémoire si l'écriture échoue
return false;
}
emit changed();
return true;
}
bool LoadConfigStore::save(QString *error) const
{
QJsonArray loads;
for (const LoadConfig &c : m_configs)
loads.append(QJsonObject::fromVariantMap(c.toMap()));
QJsonObject root;
root.insert("version", 1);
root.insert("loads", loads);
// Écriture atomique (QSaveFile = tmp + rename) — jamais de fichier mi-écrit.
QSaveFile file(filePath());
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
if (error) *error = QStringLiteral("Écriture impossible : %1").arg(file.errorString());
return false;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
if (!file.commit()) {
if (error) *error = QStringLiteral("Commit échoué : %1").arg(file.errorString());
return false;
}
qCDebug(dcNymeaEnergy()) << "[LoadConfigStore]" << m_configs.count() << "config(s) persistée(s) dans" << filePath();
return true;
}