Patrick Schurig 3ac61e9b66 docs(règles): les 14 en territoire livré, lues une par une
Sept étaient des trous de classification ou de citation, sept sont réelles.

TROIS CITATIONS AJOUTÉES, portées par du code depuis longtemps :
  LM-400    une machine physique est UNE charge → c'est l'exclusivité d'un Thing
            dans validateSet() qui l'empêche d'être déclarée deux fois
  LM-301-b  « une charge utile, une branche de fabrique » vaut pour CONSTRUIRE,
            pas pour ALIMENTER en actions → le repli L2 lit supportedKinds
  LM-600    les codes d'adapter portent le nom du MÉCANISME, jamais du domaine

QUATRE PASSÉES EN CADRAGE : ECS-004/005 (invariant tenu par ABSENCE — aucun
identifiant de relais ne franchit la frontière, il n'y a rien à pointer), LM-404
(hors périmètre déclaré), LM-501 (justification de NE PAS bâtir les groupes tant
que la liste plate suffit).

RESTENT SEPT, et ce sont de vraies attentes : ECS-111, ECS-302, ECS-305, ECS-307
(les trois dernières déjà listées en « ce qui reste à éprouver », §9 de
spec_ecs.md), LM-401/LM-402 (facettes, couche non bâtie), LM-502 (plafond de
groupe, conditionnel).

ECS-111 mérite d'être remonté : ce n'est pas une attente, c'est un défaut vivant,
et sa conséquence dépasse ce que la spec en dit. Détail dans le rendu de session.

Bilan de l'index après cette passe : 34 « spécifiée seule » (dont 27 en
territoire non construit), 26 cadrage, 24 citées par le code, 17 par un test, 12
vérifiées sur machine. Le nombre de départ était 70.

Aucun changement de comportement. 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:57:53 +02:00

254 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, et c'est ce qui
// tient \rule{LM-400} : une machine physique est UNE seule charge, jamais déclarée
// deux fois. \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;
}