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
248 lines
9.9 KiB
C++
248 lines
9.9 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
|
|
|
|
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;
|
|
}
|