Patrick Schurig 7184fe4e1d feat(etm): config charge pilotée persistée (Get/SetLoadConfig) + repli L2
Pont moteur↔app : déclaration des charges etmvariableload persistée et éditable,
construction des adaptateurs à chaud depuis la config (remplace le registre en dur).

- LoadConfig / LoadConfigNeeds (Q_GADGET typés, introspectables) — forme mot pour mot
  du LoadDescriptor §4 (jonction inter-repos avec l'app).
- LoadConfigStore : persistance atomique /var/lib/nymea/energy-load-configuration.json,
  validation en bloc, signal changed().
- Handler NymeaEnergy.GetLoadConfig / SetLoadConfig (+ notif LoadConfigChanged). GET typé
  objectRef<LoadConfig> ; SET schéma inline o: (powerLevels/needs conditionnels §4).
- EnergyArbitrator : setLoadConfigStore + rebuildEtmVariableLoadAdapters (enabled==true
  seulement, §9) ; repli L2 = setPowerSetpoint(0) force=true sur tout etmvariableload
  (ferme le trou sécurité ouvert en T2).
- Tests simulation : migration ECS→etmvariableload (arrondi fixed/dynamic, recrédit,
  délestage, round-trip powerSetpoint), budget partagé etmvariableload↔PAC, watchdog L2
  réactivé, persistance + construction depuis config + injection RPC end-to-end.

PAC SG-Ready reste hors config (§8, gelée). Les 5 tâches du brief sont couvertes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:51:38 +02:00

119 lines
3.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;
}
loaded.append(c);
}
m_configs = loaded;
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;
}
}
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;
}