Le libellé part tel quel dans chaque GetPlan. Rien ne le bornait : une saisie de plus de 1 Mio suffisait à faire dépasser la limite de ligne du protocole. La borne arrête la saisie en amont ; le refus d'émission du client (commit précédent) reste le filet. - LoadConfigStore::validateLabels(), appelée en tête de setConfigs() : un libellé de plus de 256 points de code Unicode → refus en bloc, rien n'est modifié ; SetLoadConfig rend EnergyErrorInvalidParameter et journalise le motif en français, qui nomme la charge par son id et donne la longueur, jamais le libellé. Placée AVANT isValid() parce que les motifs suivants citent le libellé. - Même partage que les rangs (LM-1209-a-bis) : load() ne l'applique pas. Un libellé plus long déjà persisté est chargé tel quel, ni tronqué ni écarté, et signalé ; la première écriture devra le raccourcir. - Test : testLabelIsBoundedOnWriteNeverOnLoad (256 accepté, 257 refusé sans modification en mémoire ni sur disque, configuration héritée conservée entière). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
12 KiB
C++
282 lines
12 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.";
|
|
}
|
|
// Q7 — même règle pour un libellé trop long : CHARGÉ TEL QUEL, jamais tronqué ni écarté. Le
|
|
// client d'optimiseur refuse d'émettre un GetPlan de plus de 1 Mio ; c'est lui le filet.
|
|
{
|
|
QString pourquoi;
|
|
if (!validateLabels(loaded, &pourquoi))
|
|
qCWarning(dcNymeaEnergy()).noquote()
|
|
<< "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi
|
|
<< "— chargé tel quel ; toute écriture sera refusée tant qu'il n'est pas raccourci.";
|
|
}
|
|
|
|
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::validateLabels(const LoadConfigs &configs, QString *error)
|
|
{
|
|
for (const LoadConfig &c : configs) {
|
|
const qsizetype caracteres = c.label().toUcs4().size();
|
|
if (caracteres > MaxLabelChars) {
|
|
if (error)
|
|
*error = QStringLiteral("le libellé de la charge \"%1\" compte %2 caractères, %3 au plus")
|
|
.arg(c.id().left(64)).arg(caracteres).arg(MaxLabelChars);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error)
|
|
{
|
|
// Les libellés D'ABORD : les motifs de refus suivants citent le libellé, et un libellé
|
|
// démesuré ne doit entrer ni dans la configuration ni dans le journal (Q7).
|
|
if (!validateLabels(configs, error))
|
|
return false;
|
|
|
|
// 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;
|
|
}
|