Même défaut que I-1, dans les avertissements de chargement ajoutés par Q7 : l'identifiant hors règle et l'échéance illisible étaient cités en noquote(). Avant Q7, un client pouvait les persister avec un saut de ligne (ni l'id ni l'échéance n'étaient bornés), et la lecture du fichier fabriquait alors une fausse ligne de journal. Ces valeurs passent par citee() : entre guillemets, échappées par QDebug (même rendu qu'avant pour une valeur ordinaire). L'avertissement LIBELLÉ TROP LONG, qui cite l'identifiant dans sa phrase, perd son noquote(). Test : testInheritedValuesAreEscapedInTheJournal charge un fichier dont l'id et l'échéance valent "x\n[NymeaEnergy] FAUX" : les trois avertissements partent, citent la valeur échappée, et aucun message n'ajoute de ligne commençant par [NymeaEnergy]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
486 lines
22 KiB
C++
486 lines
22 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 <QDebug>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QSaveFile>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QJsonArray>
|
|
#include <QJsonParseError>
|
|
#include <QRegularExpression>
|
|
|
|
namespace {
|
|
//! L'identifiant tel qu'il entre dans un motif : entier s'il est bien formé (donc borné), sinon
|
|
//! ses 64 premiers caractères — une configuration héritée peut porter n'importe quoi.
|
|
QString idPourMotif(const QString &id)
|
|
{
|
|
if (LoadConfigStore::idIsWellFormed(id) || id.toUcs4().size() <= 64)
|
|
return id;
|
|
return LoadConfigStore::leftChars(id, 64) + QStringLiteral("…");
|
|
}
|
|
|
|
//! Une valeur saisie citée dans un motif : assez pour la reconnaître, jamais en entier.
|
|
QString valeurPourMotif(const QString &valeur)
|
|
{
|
|
return valeur.toUcs4().size() <= 16 ? valeur
|
|
: LoadConfigStore::leftChars(valeur, 16) + QStringLiteral("…");
|
|
}
|
|
|
|
//! Une valeur citée au journal, entre guillemets et ÉCHAPPÉE par Qt (\c "\n" pour un saut de
|
|
//! ligne) : une valeur persistée par un client ne fabrique pas de fausse ligne de journal.
|
|
QString citee(const QString &valeur)
|
|
{
|
|
QString s;
|
|
QDebug(&s).nospace() << valeur;
|
|
return s;
|
|
}
|
|
|
|
//! Le motif publiable d'un refus PAR ENTRÉE : la cause, la position, et l'identifiant quand il
|
|
//! est bien formé — un identifiant refusé ne se recopie pas.
|
|
QVariantMap motifEntree(const char *cause, int index, const QString &id)
|
|
{
|
|
QVariantMap m{{QStringLiteral("cause"), QLatin1String(cause)}, {QStringLiteral("index"), index}};
|
|
if (LoadConfigStore::idIsWellFormed(id))
|
|
m.insert(QStringLiteral("id"), id);
|
|
return m;
|
|
}
|
|
}
|
|
|
|
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.
|
|
// Chaque entrée est signalée, pas seulement la première : c'est la liste des réparations.
|
|
// Les valeurs persistées sont citées ÉCHAPPÉES (citee(), ou `<<` sans noquote()) : avant Q7,
|
|
// un client pouvait écrire un identifiant ou une échéance portant un saut de ligne.
|
|
for (int i = 0; i < loaded.count(); ++i) {
|
|
QString pourquoi;
|
|
if (!validateLabels(LoadConfigs(QList<LoadConfig>{loaded.at(i)}), &pourquoi))
|
|
qCWarning(dcNymeaEnergy())
|
|
<< "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi
|
|
<< "— chargé tel quel ; toute écriture cliente sera refusée tant qu'il n'est pas raccourci.";
|
|
if (!idIsWellFormed(loaded.at(i).id()))
|
|
qCWarning(dcNymeaEnergy()).noquote()
|
|
<< QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, IDENTIFIANT HORS RÈGLE : "
|
|
"charge %1 (%2 caractères ; attendu 1 à %3 caractères "
|
|
"[A-Za-z0-9._{}-]) — chargée telle quelle ; toute écriture cliente "
|
|
"sera refusée tant qu'elle porte cet identifiant.")
|
|
.arg(citee(idPourMotif(loaded.at(i).id()))).arg(loaded.at(i).id().toUcs4().size())
|
|
.arg(MaxIdChars);
|
|
// L'échéance, elle, est RETIRÉE de la mémoire — l'entrée reste. Une échéance illisible
|
|
// ne se résout en aucun instant ; la garder la ferait partir brute dans GetPlan, où
|
|
// l'optimiseur devrait deviner ce qu'elle veut dire. Sans elle, une obligation en énergie
|
|
// n'a plus de cadence et rien n'est acheté pour elle : le côté sûr. Le fichier n'est pas
|
|
// réécrit ici ; la première écriture cliente le remplacera.
|
|
const QString echeance = loaded.at(i).needs().dailyDeadline();
|
|
if (!deadlineIsWellFormed(echeance)) {
|
|
qCWarning(dcNymeaEnergy()).noquote()
|
|
<< QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, ÉCHÉANCE ILLISIBLE : "
|
|
"charge %1, needs.dailyDeadline %2 (attendu HH:MM, de 00:00 "
|
|
"à 23:59) — IGNORÉE : ni l'arbitre ni l'optimiseur ne la reçoivent, "
|
|
"l'obligation n'a plus d'échéance.")
|
|
.arg(citee(idPourMotif(loaded.at(i).id())), citee(valeurPourMotif(echeance)));
|
|
LoadConfigNeeds besoins = loaded.at(i).needs();
|
|
besoins.setDailyDeadline(QString());
|
|
loaded[i].setNeeds(besoins);
|
|
}
|
|
}
|
|
|
|
m_configs = loaded;
|
|
return true;
|
|
}
|
|
|
|
bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error, QVariantMap *fault)
|
|
{
|
|
auto fail = [error, fault](const QString &why, const QVariantMap &motif) {
|
|
if (error) *error = why;
|
|
if (fault) *fault = motif;
|
|
return false;
|
|
};
|
|
|
|
QSet<QString> ids;
|
|
QHash<QString, int> proprietaire; // ThingId normalisé → index de la charge
|
|
|
|
for (int i = 0; i < configs.count(); ++i) {
|
|
const LoadConfig &c = configs.at(i);
|
|
if (ids.contains(c.id()))
|
|
return fail(QStringLiteral("l'identifiant de charge \"%1\" apparaît deux fois").arg(idPourMotif(c.id())),
|
|
motifEntree("ID_DUPLICATE", i, 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()) {
|
|
if (proprietaire.contains(thing)) {
|
|
const int j = proprietaire.value(thing);
|
|
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, configs.at(j).label(), c.label()),
|
|
QVariantMap{{QStringLiteral("cause"), QStringLiteral("THING_SHARED")},
|
|
{QStringLiteral("indexes"), QVariantList{j, i}},
|
|
{QStringLiteral("ids"), QVariantList{configs.at(j).id(), c.id()}}});
|
|
}
|
|
proprietaire.insert(thing, i);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error, QVariantMap *fault)
|
|
{
|
|
QHash<int, int> parRang; // rang → index de la première charge qui le porte
|
|
for (int i = 0; i < configs.count(); ++i) {
|
|
const LoadConfig &c = configs.at(i);
|
|
if (parRang.contains(c.priority())) {
|
|
const int j = parRang.value(c.priority());
|
|
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(configs.at(j).label(), c.label()).arg(c.priority());
|
|
if (fault)
|
|
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("RANK_DUPLICATE")},
|
|
{QStringLiteral("indexes"), QVariantList{j, i}},
|
|
{QStringLiteral("ids"), QVariantList{configs.at(j).id(), c.id()}},
|
|
{QStringLiteral("rank"), c.priority()}};
|
|
return false;
|
|
}
|
|
parRang.insert(c.priority(), i);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
|
|
QString *error, QVariantMap *fault) const
|
|
{
|
|
QHash<QString, const LoadConfig *> stocke;
|
|
for (const LoadConfig &c : m_configs)
|
|
stocke.insert(c.id(), &c);
|
|
|
|
LoadConfigs resultat;
|
|
for (int i = 0; i < incoming.count(); ++i) {
|
|
LoadConfig c = incoming.at(i);
|
|
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());
|
|
if (fault)
|
|
*fault = motifEntree("RANK_ORIGIN_AUTO", i, c.id());
|
|
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, QVariantMap *fault,
|
|
int from)
|
|
{
|
|
for (int i = qMax(0, from); i < configs.count(); ++i) {
|
|
const LoadConfig &c = configs.at(i);
|
|
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(idPourMotif(c.id())).arg(caracteres).arg(MaxLabelChars);
|
|
if (fault) {
|
|
*fault = motifEntree("LABEL", i, c.id());
|
|
fault->insert(QStringLiteral("length"), caracteres);
|
|
fault->insert(QStringLiteral("max"), MaxLabelChars);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool LoadConfigStore::idIsWellFormed(const QString &id)
|
|
{
|
|
// Le jeu est ASCII : un caractère y vaut une unité UTF-16, et size() compte des caractères.
|
|
static const QRegularExpression forme(
|
|
QRegularExpression::anchoredPattern(QStringLiteral("[A-Za-z0-9._{}-]+")));
|
|
return id.size() <= MaxIdChars && forme.match(id).hasMatch();
|
|
}
|
|
|
|
bool LoadConfigStore::validateIds(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from)
|
|
{
|
|
static const QRegularExpression horsJeu(QStringLiteral("[^A-Za-z0-9._{}-]"));
|
|
for (int i = qMax(0, from); i < configs.count(); ++i) {
|
|
const QString id = configs.at(i).id();
|
|
if (idIsWellFormed(id))
|
|
continue;
|
|
const qsizetype caracteres = id.toUcs4().size();
|
|
// La position du premier caractère hors jeu, en caractères — pas en unités UTF-16.
|
|
const QRegularExpressionMatch m = horsJeu.match(id);
|
|
const qsizetype position = m.hasMatch() ? id.left(m.capturedStart()).toUcs4().size() : -1;
|
|
if (error) {
|
|
*error = caracteres == 0
|
|
? QStringLiteral("l'identifiant de la charge en position %1 est vide").arg(i + 1)
|
|
: position >= 0
|
|
? QStringLiteral("l'identifiant de la charge en position %1 contient un caractère hors "
|
|
"[A-Za-z0-9._{}-] (caractère %2)").arg(i + 1).arg(position + 1)
|
|
: QStringLiteral("l'identifiant de la charge en position %1 compte %2 caractères, %3 au plus")
|
|
.arg(i + 1).arg(caracteres).arg(MaxIdChars);
|
|
}
|
|
if (fault) {
|
|
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("ID")},
|
|
{QStringLiteral("index"), i},
|
|
{QStringLiteral("length"), caracteres},
|
|
{QStringLiteral("max"), MaxIdChars}};
|
|
if (position >= 0)
|
|
fault->insert(QStringLiteral("position"), position);
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool LoadConfigStore::deadlineIsWellFormed(const QString &deadline)
|
|
{
|
|
// anchoredPattern, et non ^…$ : « $ » accepterait un saut de ligne final.
|
|
static const QRegularExpression forme(
|
|
QRegularExpression::anchoredPattern(QStringLiteral("([01][0-9]|2[0-3]):[0-5][0-9]")));
|
|
return deadline.isEmpty() || forme.match(deadline).hasMatch();
|
|
}
|
|
|
|
bool LoadConfigStore::validateDeadlines(const LoadConfigs &configs, QString *error, QVariantMap *fault,
|
|
int from)
|
|
{
|
|
for (int i = qMax(0, from); i < configs.count(); ++i) {
|
|
const LoadConfig &c = configs.at(i);
|
|
if (deadlineIsWellFormed(c.needs().dailyDeadline()))
|
|
continue;
|
|
if (error)
|
|
*error = QStringLiteral("l'échéance quotidienne de la charge \"%1\" est illisible (\"%2\") : "
|
|
"attendu HH:MM, de 00:00 à 23:59, ou absente")
|
|
.arg(idPourMotif(c.id()), valeurPourMotif(c.needs().dailyDeadline()));
|
|
if (fault)
|
|
*fault = motifEntree("DEADLINE", i, c.id());
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool LoadConfigStore::validateBounds(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from)
|
|
{
|
|
// L'identifiant D'ABORD : tous les motifs suivants le recopient.
|
|
return validateIds(configs, error, fault, from)
|
|
&& validateLabels(configs, error, fault, from)
|
|
&& validateDeadlines(configs, error, fault, from);
|
|
}
|
|
|
|
QString LoadConfigStore::leftChars(const QString &texte, int n)
|
|
{
|
|
// Couper en points de code puis recomposer : une paire de substitution reste entière.
|
|
const QList<uint> points = texte.toUcs4();
|
|
if (points.size() <= n)
|
|
return texte;
|
|
return QString::fromUcs4(reinterpret_cast<const char32_t *>(points.constData()), qMax(0, n));
|
|
}
|
|
|
|
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error, QVariantMap *fault)
|
|
{
|
|
return commit(configs, 0, error, fault);
|
|
}
|
|
|
|
bool LoadConfigStore::appendConfig(const LoadConfig &config, QString *error, QVariantMap *fault)
|
|
{
|
|
LoadConfigs configs = m_configs;
|
|
configs.append(config);
|
|
// Les bornes de saisie ne portent que sur l'entrée APPORTÉE — voir la déclaration.
|
|
return commit(configs, configs.count() - 1, error, fault);
|
|
}
|
|
|
|
bool LoadConfigStore::commit(const LoadConfigs &configs, int premierBorne, QString *error, QVariantMap *fault)
|
|
{
|
|
// Les bornes de saisie D'ABORD : les motifs de refus suivants citent l'identifiant et le
|
|
// libellé, et une saisie démesurée ne doit entrer ni dans la configuration ni dans le
|
|
// journal (Q7).
|
|
if (!validateBounds(configs, error, fault, premierBorne))
|
|
return false;
|
|
|
|
// Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté).
|
|
for (int i = 0; i < configs.count(); ++i) {
|
|
const LoadConfig &c = configs.at(i);
|
|
QString why;
|
|
QVariantMap motif;
|
|
if (!c.isValid(&why, &motif)) {
|
|
if (error) *error = QStringLiteral("Config \"%1\" invalide : %2").arg(c.label(), why);
|
|
if (fault) {
|
|
*fault = motif;
|
|
fault->insert(QStringLiteral("index"), i);
|
|
if (idIsWellFormed(c.id()))
|
|
fault->insert(QStringLiteral("id"), c.id());
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
// …puis la cohérence d'ensemble, qu'aucune charge ne peut vérifier seule.
|
|
if (!validateSet(configs, error, fault))
|
|
return false;
|
|
|
|
// …puis la BONNE FORME, qui ne s'applique qu'ici. load() n'écarte rien sur sa foi : elle
|
|
// refuse une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a).
|
|
if (!validateRanks(configs, error, fault))
|
|
return false;
|
|
|
|
const LoadConfigs previous = m_configs;
|
|
m_configs = configs;
|
|
|
|
QString echec;
|
|
if (!save(&echec)) {
|
|
m_configs = previous; // rollback mémoire si l'écriture échoue
|
|
// La cause système est dite ICI : le refus de SetLoadConfig ne journalise que son motif,
|
|
// et WRITE_FAILED ne dit pas pourquoi le disque a refusé. Aucune saisie n'y entre.
|
|
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Écriture de la configuration échouée :"
|
|
<< filePath() << echec;
|
|
if (error) *error = echec;
|
|
if (fault)
|
|
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("WRITE_FAILED")}};
|
|
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;
|
|
}
|