// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "storagedischargeguard.h" #include "plugininfo.h" #include #include #include namespace { const QString EtatLimite = QStringLiteral("enableDischarging"); // bit DiSCHARGE de StorCtl_Mod const QString EtatTaux = QStringLiteral("dischargingRate"); // OutWRte, % de la puissance max //! Délai minimal entre deux réécritures déclenchées par une batterie qui ne rapporte plus la //! consigne : l'état relu d'un onduleur suit l'écriture d'un poll (≈ un cycle). Réécrire plus //! vite reviendrait à réécrire ce qui est en vol. constexpr int ReecritureMinS = 55; //! Après le n-ième refus d'affilée d'une pose, la suivante attend `EspacementRefusS[n - 1]` //! secondes (le dernier palier ensuite) : 1, 2, 5, 10 puis 15 min. Un onduleur qui refuse (GEN24 //! sans « Allow Control », exception Modbus 0x04) refusera encore au cycle suivant ; insister à //! chaque minute toute la nuit n'apprend rien et charge le bus. Quinze minutes au plus : un //! réglage corrigé en cours de nuit est repris dans le quart d'heure. constexpr int EspacementRefusS[] = {60, 120, 300, 600, 900}; constexpr int PaliersRefus = int(sizeof(EspacementRefusS) / sizeof(EspacementRefusS[0])); //! Un cycle « par minute » arrive à quelques secondes près : sans cette marge, l'attente d'une //! minute en coûterait deux (même raison que \c ReecritureMinS). constexpr int MargeCycleS = 5; //! La valeur de `dischargingRate` qui rend la décharge LIBRE : le maximum de l'état, 100 % à défaut. double tauxLibre(Thing *t) { const QVariant max = t->thingClass().stateTypes().findByName(EtatTaux).maxValue(); return max.isValid() ? max.toDouble() : 100.0; } } StorageDischargeGuard::StorageDischargeGuard(ThingManager *tm, QObject *parent) : QObject(parent) , m_tm(tm) { } SurplusContext::StorageControl StorageDischargeGuard::capacite(Thing *t) { SurplusContext::StorageControl c; if (!t) return c; c.thingId = t->id().toString(); // La question est posée à la CLASSE : les deux états existent, sont modifiables, ont leur // action, et la limite admet 0 %. Une valeur d'état ne dit rien de ce qu'on peut écrire. StateTypes etats = t->thingClass().stateTypes(); ActionTypes actions = t->thingClass().actionTypes(); const StateType limite = etats.findByName(EtatLimite); const StateType taux = etats.findByName(EtatTaux); const bool pilotable = !limite.id().isNull() && limite.writable() && limite.type() == QMetaType::Bool && !actions.findByName(EtatLimite).id().isNull() && !taux.id().isNull() && taux.writable() && !actions.findByName(EtatTaux).id().isNull() && (!taux.minValue().isValid() || taux.minValue().toDouble() <= 0.0); if (!pilotable) { c.cause = QStringLiteral("noDischargeControl"); return c; } // LE MINUTEUR DE RETOUR, et c'est lui la sûreté : sans lui, un moteur arrêté laisserait la // batterie bloquée indéfiniment. On ne commande pas ce qui ne sait pas revenir seul. const ParamType minuteur = t->thingClass().paramTypes().findByName(QStringLiteral("revertTime")); const uint revertS = minuteur.id().isNull() ? 0 : t->paramValue(minuteur.id()).toUInt(); if (revertS == 0) { c.cause = QStringLiteral("noRevertTimer"); return c; } if (revertS < uint(MinRevertTimeS)) { c.cause = QStringLiteral("revertTimerTooShort"); return c; } c.controllable = true; return c; } QList StorageDischargeGuard::capacites() const { QList l; if (!m_tm) return l; for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage"))) l << capacite(t); return l; } void StorageDischargeGuard::appliquer(bool configure, bool bloquer, const QDateTime &now) { if (!m_tm) return; // Une batterie disparue n'a plus d'état à tenir. for (auto it = m_etats.begin(); it != m_etats.end();) { if (!m_tm->findConfiguredThing(ThingId(it.key()))) it = m_etats.erase(it); else ++it; } for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage"))) { const QString id = t->id().toString(); // SANS heures creuses, rien n'est lu ni écrit — sauf une consigne qu'on tiendrait encore. if (!configure && !(m_etats.contains(id) && m_etats.value(id).tenue)) continue; const SurplusContext::StorageControl cap = capacite(t); if (!cap.controllable) continue; // lue comme avant le lot ; le motif STORAGE_NOT_PROTECTED le dit Etat &e = m_etats[id]; const bool limiteActive = t->stateValue(EtatLimite).toBool(); const bool aZero = qFuzzyIsNull(t->stateValue(EtatTaux).toDouble()); if (!e.vue) { e.vue = true; // ADOPTION — une limite à 0 % déjà active au premier regard est celle qu'un moteur // précédent a laissée (redémarrage de nymead). La laisser expirer contredirait // « consigne levée explicitement » ; la reprendre la fait rafraîchir ou lever. if (configure && limiteActive && aZero) { e.tenue = true; e.depuis = now; qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— limite de décharge à 0 % trouvée active au démarrage : reprise."; } } if (bloquer) { // SÉRIE DE REFUS : pas de nouvelle pose avant l'échéance de l'espacement. La levée, // elle, n'attend jamais (branche ci-dessous). if (e.refus > 0 && now < e.prochainEssai.addSecs(-MargeCycleS)) continue; const qint64 age = e.derniereEcriture.isValid() ? e.derniereEcriture.secsTo(now) : -1; const int revertS = t->paramValue(t->thingClass().paramTypes() .findByName(QStringLiteral("revertTime")).id()).toInt(); // Le TIERS du minuteur : à 300 s, une réécriture toutes les 100 s au plus tard ; à un // cycle par minute, l'écart réel entre deux écritures reste sous 180 s, cycle manqué // compris — bien avant l'expiration. const bool echu = age < 0 || age >= revertS / 3; // L'onduleur ne rapporte plus la consigne : il est revenu seul à l'automatisme (un // minuteur expiré, un redémarrage) — on la repose sans attendre l'échéance. const bool perdue = e.tenue && e.enCours == 0 && !(limiteActive && aZero) && age >= ReecritureMinS; if (!e.tenue || echu || perdue) bloquerUne(t, e, now, e.tenue && !perdue); } else if (e.tenue) { leverUne(t, e, now); } } } void StorageDischargeGuard::leverTout(const QDateTime &now) { if (!m_tm) return; for (auto it = m_etats.begin(); it != m_etats.end(); ++it) { Thing *t = m_tm->findConfiguredThing(ThingId(it.key())); if (t && it.value().tenue) leverUne(t, it.value(), now); } } bool StorageDischargeGuard::tientUneConsigne() const { for (const Etat &e : m_etats) if (e.tenue && e.refus == 0) return true; return false; } QVariantList StorageDischargeGuard::vue() const { QVariantList l; if (!m_tm) return l; for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage"))) { const SurplusContext::StorageControl cap = capacite(t); const Etat e = m_etats.value(cap.thingId); QVariantMap m{{QStringLiteral("thingId"), cap.thingId}, {QStringLiteral("controllable"), cap.controllable}, {QStringLiteral("held"), e.tenue && e.refus == 0}}; if (!cap.cause.isEmpty()) m.insert(QStringLiteral("cause"), cap.cause); if (e.tenue && e.refus == 0 && e.depuis.isValid()) m.insert(QStringLiteral("heldSince"), e.depuis.toUTC().toString(Qt::ISODate)); // UNE SÉRIE DE REFUS : `held` est faux — l'onduleur ne tient pas ce qu'il refuse — et ces // trois champs disent pourquoi, et quand le moteur réessaiera. if (e.tenue && e.refus > 0) { m.insert(QStringLiteral("refusedWrites"), e.refus); m.insert(QStringLiteral("refusedSince"), e.refusDepuis.toUTC().toString(Qt::ISODate)); m.insert(QStringLiteral("nextAttemptAt"), e.prochainEssai.toUTC().toString(Qt::ISODate)); } if (e.derniereEcriture.isValid()) m.insert(QStringLiteral("lastWriteAt"), e.derniereEcriture.toUTC().toString(Qt::ISODate)); // CE QUI A ÉTÉ LU, pas ce qu'on a écrit : c'est la seule preuve que la batterie a suivi. QVariantMap lu; if (!t->thingClass().stateTypes().findByName(EtatLimite).id().isNull()) lu.insert(EtatLimite, t->stateValue(EtatLimite).toBool()); if (!t->thingClass().stateTypes().findByName(EtatTaux).id().isNull()) lu.insert(EtatTaux, t->stateValue(EtatTaux).toDouble()); if (!lu.isEmpty()) m.insert(QStringLiteral("observed"), lu); l << m; } return l; } void StorageDischargeGuard::bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement) { if (e.tenue && e.refus > 0) qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— nouvelle tentative de pose après" << e.refus << "refus."; else if (rafraichissement) qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— limite de décharge à 0 % rafraîchie (minuteur de retour réarmé)."; else qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— décharge BLOQUÉE : limite de décharge à 0 %" << "(dischargingRate = 0, puis enableDischarging = true)."; if (!e.tenue) e.depuis = now; e.tenue = true; e.derniereEcriture = now; // ORDRE : le taux d'abord, la limite ensuite. Tant que la limite est inactive, le taux est // inerte ; quand elle s'active, il vaut déjà 0. L'inverse activerait une limite au taux // précédent, peut-être 100 %, le temps d'une écriture. Si la première échoue, la seconde ne // part pas : la limite reste inactive, donc la batterie en automatique — le sens sûr. // La séquence est ACCEPTÉE quand la seconde écriture l'est ; un refus de l'une OU de l'autre // compte pour la série. const QString id = t->id().toString(); const auto refus = [this, id](const QString &etat, const QString &cause) { noterRefus(id, etat, cause); }; ecrire(t, EtatTaux, 0, [this, id, refus]() { if (Thing *suite = m_tm->findConfiguredThing(ThingId(id))) ecrire(suite, EtatLimite, true, [this, id]() { noterAcceptation(id); }, refus); }, refus); } void StorageDischargeGuard::noterRefus(const QString &id, const QString &etat, const QString &cause) { if (!m_etats.contains(id) || !m_etats.value(id).tenue) return; // la consigne a été levée entre-temps : ce verdict tardif ne pèse plus Etat &e = m_etats[id]; Thing *t = m_tm->findConfiguredThing(ThingId(id)); const QString nom = t ? t->name() : id; const int attente = EspacementRefusS[qMin(e.refus + 1, PaliersRefus) - 1]; // UN SEUL AVERTISSEMENT PAR SÉRIE : le premier refus est aussi visible que l'application // (règle 7-d) ; les suivants ne disent rien de neuf, et la télémétrie les compte. if (e.refus == 0) { e.refusDepuis = e.derniereEcriture; qCWarning(dcNymeaEnergy()).noquote() << "[Batterie]" << nom << "— écriture de" << etat << QStringLiteral("REFUSÉE (%1) : la décharge").arg(cause) << "n'est PAS bloquée. Nouvelles tentatives espacées (1, 2, 5, 10 puis 15 min) ; ce" << "refus n'est plus signalé qu'en débogage jusqu'à la première écriture acceptée."; } else { qCDebug(dcNymeaEnergy()).noquote() << "[Batterie]" << nom << "— écriture de" << etat << QStringLiteral("encore refusée (%1),").arg(cause) << e.refus + 1 << "refus d'affilée ; prochain essai dans" << attente << "s."; } ++e.refus; e.prochainEssai = e.derniereEcriture.addSecs(attente); } void StorageDischargeGuard::noterAcceptation(const QString &id) { if (!m_etats.contains(id)) return; Etat &e = m_etats[id]; if (e.refus > 0) { Thing *t = m_tm->findConfiguredThing(ThingId(id)); qCInfo(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) << "— limite de décharge à 0 % ACCEPTÉE après" << e.refus << "refus : décharge bloquée, retour au rythme normal."; } e.refus = 0; e.refusDepuis = QDateTime(); e.prochainEssai = QDateTime(); } void StorageDischargeGuard::leverUne(Thing *t, Etat &e, const QDateTime &now) { qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— décharge RENDUE à l'automatisme : limite levée" << "(enableDischarging = false, puis dischargingRate =" << tauxLibre(t) << ")."; e.tenue = false; e.depuis = QDateTime(); e.derniereEcriture = now; // La levée CLÔT une série de refus : la prochaine pose est une nouvelle consigne, au rythme // normal, et son premier refus sera de nouveau signalé. e.refus = 0; e.refusDepuis = QDateTime(); e.prochainEssai = QDateTime(); // ORDRE INVERSE : lever la limite rend l'automatisme TOUT DE SUITE ; le taux suit, pour // qu'une limite réactivée plus tard par quiconque ne reparte pas à 0 %. La seconde part même // si la première échoue : remettre le taux au maximum ne bloque rien. ecrire(t, EtatLimite, false); ecrire(t, EtatTaux, tauxLibre(t)); } void StorageDischargeGuard::ecrire(Thing *t, const QString &etat, const QVariant &valeur, const std::function &suite, const std::function &refus) { const StateType st = t->thingClass().stateTypes().findByName(etat); Action action(st.id(), t->id(), Action::TriggeredByRule); action.setParams(ParamList() << Param(st.id(), valeur)); ThingActionInfo *info = m_tm->executeAction(action); if (!info) { const QString cause = QStringLiteral("aucune réponse du gestionnaire"); if (refus) refus(etat, cause); else qCWarning(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— écriture de" << etat << "impossible (" << cause << ")."; return; } const QString id = t->id().toString(); ++m_etats[id].enCours; // Aucune attente : verdict par signal, `this` en contexte (même modèle qu'ECS-414). Un REFUS // est au moins aussi visible que l'application (règle 7-d) — une fois par série pour la pose // (\c noterRefus), à chaque fois pour la levée, qui n'est jamais répétée. connect(info, &ThingActionInfo::finished, this, [this, info, id, etat, valeur, suite, refus]() { if (m_etats.contains(id)) --m_etats[id].enCours; if (info->status() != Thing::ThingErrorNoError) { if (refus) { QString statut; QDebug(&statut).nospace() << info->status(); refus(etat, QStringLiteral("statut ") + statut); return; } Thing *t = m_tm->findConfiguredThing(ThingId(id)); qCWarning(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) << "— écriture de" << etat << "=" << valeur << "REFUSÉE, statut" << info->status(); return; } if (suite) suite(); }); }