Patrick Schurig c9b8e63f89 fix(etm): ECS-412 — rebuild incrémental et armement du verrou à froid
Cause racine, pas symptôme. rebuildLoadAdapters() détruisait TOUS les adaptateurs
à chaque SetLoadConfig, réarmant leurs verrous. Sur un ballon thermodynamique à
minOn de 300-600 s, un client qui réordonne ses priorités depuis l'app pouvait
faire court-cycler son compresseur. C'est de la protection matérielle.

L'arbitre mémorise désormais la config ayant servi à construire chaque adaptateur
(m_builtFrom) et ne reconstruit que si le MATÉRIEL a changé — type, câblage,
paliers, plafond, verrous (sameHardware()). Un changement de rang ou de besoins
passe par updateSoftConfig(), en place : ni m_currentStage ni m_lastSwitch ne
bougent, aucun relais n'est réécrit. Le log distingue créées / mises à jour /
inchangées / retirées.

updateSoftConfig est PURE VIRTUELLE, sans implémentation par défaut. Un défaut
vide silencieux ferait qu'un futur adaptateur construit depuis LoadConfig
ignorerait sans bruit les changements de rang ; là, le compilateur force la
décision. EvAdapter et SgReadyAdapter la déclarent sans effet, avec le motif.

Démarrage à froid : après un redémarrage de nymead, m_lastSwitch est
irrécupérable. lockWindow() traite désormais un horodatage nul comme
« commutation venant d'avoir lieu » (elapsed = 0), donc verrou ARMÉ pour sa durée
configurée. L'écriture naturelle (`valid && elapsed < minOnS`) fait l'inverse et
laisserait une boucle de redémarrage court-circuiter la protection compresseur
quand elle est la plus nécessaire. Armer n'est pas verrouiller
inconditionnellement : avec une durée nulle, `0 < 0` est faux et le verrou reste
inactif — un premier jet qui forçait le verrou a fait tomber trois tests
existants, qui avaient raison.

Test : testEcsRebuildPreservesLock — SetLoadConfig pendant une fenêtre de verrou
active, le relais reste fermé ; le délestage reprend une fois minOn écoulé.

Build amd64 0 erreur. Simulation : 12/12.

Réf. specs/spec_ecs.md §3 ECS-412 (0.5.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:42:56 +02:00

90 lines
3.9 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include "iloadadapter.h"
class EvCharger;
class EnergyArbitrator;
/*!
* \brief Adaptateur pour une borne de recharge VE (interface evcharger nymea).
*
* Traduit les LoadAction(Setpoint) en appels matériels via
* EnergyArbitrator::doExecuteChargingAction() — le seul chemin d'exécution.
*
* \invariant applyAction() rejette silencieusement toute LoadAction dont \c reason est vide.
* \invariant currentA est écrêté à [maxChargingCurrentMinValue, maxChargingCurrentMaxValue].
* \invariant phaseCount est écrêté selon canSetPhaseCount() et phaseCount() du EvCharger.
* \invariant Les kinds autres que Setpoint sont retournés sans effet.
*/
class EvAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param evCharger Borne VE à piloter (doit rester valide tant que l'adaptateur existe).
* \param parent EnergyArbitrator propriétaire — utilisé pour l'exécution matérielle.
*/
explicit EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent);
/*!
* \brief Retourne la description statique de la charge.
* \return LoadDescriptor construit depuis les capacités actuelles du EvCharger.
* \note Recalculé à chaque appel depuis l'état nymea du Thing.
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Retourne la télémétrie runtime (puissance mesurée, disponibilité).
* \return LoadTelemetry avec currentPowerW, available et lastActionAt.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). Inutilisé ici : l'EV n'a pas de verrou
* de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter.
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique une consigne Setpoint sur la borne VE.
*
* **Inactif jusqu'à 3g** : non appelée par \c EnergyArbitrator::update() en beta.
* Le dispatch EV passe par \c adjustEvChargers() amont (hérité). Cette méthode
* sera câblée lors de la transplantation EV dans \c RuleBasedScheduler (phase 3g).
* \c descriptor() et \c telemetry() sont eux actifs dès maintenant pour le SurplusContext.
*
* \param action LoadAction de kind Setpoint. Autres kinds : retour sans effet.
* \param now Temps de cycle (\c ctx.timestamp) — passé à \c doExecuteChargingAction()
* (locks anti-rebond de la borne). MÊME source que toLoadContext() (contrat ILoadAdapter).
* \return L'action après écrêtage matériel (currentA, phaseCount bornés).
*
* \invariant action.reason non vide requis — log warning et retour sans effet sinon.
* \invariant currentA écrêté à [minValue, maxValue] avant envoi à executeChargingAction.
* \invariant phaseCount ajusté selon canSetPhaseCount() du EvCharger.
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
//! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig (rang fixé à 100,
//! cf. « Dette 3g »). \param priority Ignoré. \param needs Ignoré.
void updateSoftConfig(int priority, const LoadNeeds &needs) override
{ Q_UNUSED(priority) Q_UNUSED(needs) }
/*!
* \brief Borne VE sous-jacente (lecture).
* \return Pointeur non-propriétaire vers l'EvCharger ; jamais nul (fixé à la construction).
*/
EvCharger *evCharger() const { return m_charger; }
private:
EvCharger *m_charger;
EnergyArbitrator *m_parent;
QDateTime m_lastActionAt;
};