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>
164 lines
5.7 KiB
C++
164 lines
5.7 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);
|
|
}
|
|
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
|
|
|
|
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::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;
|
|
|
|
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;
|
|
}
|