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>
142 lines
7.1 KiB
C++
142 lines
7.1 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 <QList>
|
|
#include <QString>
|
|
#include "iloadadapter.h"
|
|
|
|
class Thing;
|
|
class ThingManager;
|
|
|
|
/*!
|
|
* \brief Adaptateur charge à puissance pilotable — interface \c etmvariableload (contrat
|
|
* INTERFACE_etmvariableload.md). Action \c kind:Setpoint en WATTS.
|
|
*
|
|
* Couvre les charges dont la consigne est une puissance : ECS multipalier, routeur PV /
|
|
* triac, résistance modulable. \b Pas la PAC SG-Ready (modèle à états, \c SgReadyAdapter).
|
|
*
|
|
* \par Frontière (contrat §1 / §5) — non négociable
|
|
* L'energymanager raisonne UNIQUEMENT en watts. \b Aucune résistance, relais, combinaison
|
|
* ni index de palier matériel ne traverse cet adaptateur. La connaissance matérielle (quelle
|
|
* résistance, quel triac, sur quelle phase), l'anti-rebond (minOn/minOff) et l'équilibrage
|
|
* de phases vivent ENTIÈREMENT dans le \b thing qui implémente l'interface. Cet adaptateur
|
|
* se contente d'écrire une consigne \c powerSetpoint (W) et de relire \c currentPowerW.
|
|
*
|
|
* \par States nymea consommés sur le thing cible (\c m_id)
|
|
* - \c currentPowerW (read) : puissance réellement appliquée — juge runtime (contrat §4).
|
|
* - \c powerSetpoint (write) : consigne demandée par l'energymanager.
|
|
* - \c maxPowerW / \c powerLevels (read) : déclarés par le thing pour le wizard ; la
|
|
* planification utilise la DÉCLARATION de config (passée au constructeur, contrat §4),
|
|
* pas ces states — l'adaptateur n'écrase jamais la déclaration.
|
|
*
|
|
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
|
|
* \invariant Seul le kind \c Setpoint est traité ; les autres kinds retournent sans effet.
|
|
* \invariant La consigne est écrêtée à \c [0, maxPowerW] avant envoi matériel (second filet
|
|
* après l'écrêtage de l'arbitre). L'arrondi au \c powerLevels (mode *fixed*) est la
|
|
* responsabilité du SCHEDULER (contrat §4), pas de l'adaptateur.
|
|
* \invariant **Temps = paramètre, jamais l'horloge** (cf. \c ILoadAdapter) : aucune logique
|
|
* temporelle ici (les verrous vivent dans le thing) — \c now sert uniquement aux estampilles.
|
|
*
|
|
* \note T1 (squelette) : l'adaptateur consomme l'interface côté moteur. La déclaration de
|
|
* l'interface nymea \c etmvariableload et le driver thing relèvent d'une session device dédiée.
|
|
*/
|
|
class EtmVariableLoadAdapter : public QObject, public ILoadAdapter
|
|
{
|
|
Q_OBJECT
|
|
public:
|
|
/*!
|
|
* \brief Constructeur.
|
|
* \param thingManager Gestionnaire nymea pour résoudre \p id en Thing.
|
|
* \param id ThingId (string) du thing implémentant \c etmvariableload, et
|
|
* identifiant logique de la charge.
|
|
* \param label Nom lisible (logs, app).
|
|
* \param powerLevels Paliers atteignables en W (DÉCLARÉS, triés croissants, \c 0 inclus)
|
|
* en mode *fixed* ; liste vide ⇒ *dynamic* (modulation 0..maxPowerW).
|
|
* \param maxPowerW Plafond physique (W) — contrat §2.
|
|
* \param priority Rang dans le waterfall (contrat §5 / OPTIMIZER_PROTOCOL §5) : valeur
|
|
* plus BASSE = servi en premier (rang 1 = premier servi).
|
|
* \param needs Besoins déclarés (échéances, énergie min) — exposés au scheduler
|
|
* via \c descriptor().needs. Vide par défaut.
|
|
* \param parent Propriétaire Qt.
|
|
*/
|
|
explicit EtmVariableLoadAdapter(ThingManager *thingManager,
|
|
const QString &id,
|
|
const QString &label,
|
|
const QList<int> &powerLevels,
|
|
int maxPowerW,
|
|
int priority,
|
|
const LoadNeeds &needs = LoadNeeds(),
|
|
QObject *parent = nullptr);
|
|
|
|
/*!
|
|
* \brief Description statique : adapter="etmvariableload", powerLevels, maxPowerW, priority.
|
|
* \return LoadDescriptor déclaratif (référence de planification, contrat §4).
|
|
*/
|
|
LoadDescriptor descriptor() const override;
|
|
|
|
/*!
|
|
* \brief Télémétrie runtime. \c currentPowerW = state \c currentPowerW MESURÉ du thing
|
|
* (juge de quantification, contrat §4) ; \c available faux si le thing est absent.
|
|
* \return LoadTelemetry ; \c currentPowerW vaut 0 si le thing est absent ou n'expose
|
|
* pas l'état \c currentPowerW (jamais de puissance fantôme).
|
|
*/
|
|
LoadTelemetry telemetry() const override;
|
|
|
|
/*!
|
|
* \brief Construit l'entrée loads[] §5 (adapter="etmvariableload").
|
|
* \param now Temps de cycle (\c ctx.timestamp) — estampille uniquement ; aucune fenêtre de
|
|
* verrou côté moteur (l'anti-rebond vit dans le thing, contrat §2).
|
|
* \return LoadContext incluant declared (powerLevels/maxPowerW) et \c currentPowerW.
|
|
*/
|
|
LoadContext toLoadContext(const QDateTime &now) const override;
|
|
|
|
/*!
|
|
* \brief Écrit la consigne \c powerSetpoint (W) sur le thing.
|
|
* \param action LoadAction de kind \c Setpoint ; consigne lue dans \c action.powerW. Autres
|
|
* kinds : retour sans effet.
|
|
* \param now Temps de cycle (\c ctx.timestamp) — estampille \c m_lastActionAt.
|
|
* \return L'action après écrêtage matériel (\c powerW borné à [0, maxPowerW]).
|
|
*
|
|
* \invariant Si \c action.reason est vide → retour sans effet (log warning).
|
|
* \invariant \c action.force == true (repli L2 : \c setPowerSetpoint(0)) est transmis tel
|
|
* quel ; le bypass anti-rebond est honoré par le thing, pas ici.
|
|
*/
|
|
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
|
|
|
//! \brief Met à jour rang et besoins sans reconstruire (ECS-412).
|
|
//! \param priority Nouveau rang de service. \param needs Nouveaux besoins.
|
|
void updateSoftConfig(int priority, const LoadNeeds &needs) override
|
|
{ m_priority = priority; m_needs = needs; }
|
|
|
|
/*!
|
|
* \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing).
|
|
* \return Consigne commandée en W, bornée à \c maxPowerW ; 0 tant qu'aucune action
|
|
* n'a été appliquée. C'est le COMMANDÉ, pas le mesuré.
|
|
*/
|
|
double currentSetpointW() const { return m_currentSetpointW; }
|
|
|
|
private:
|
|
//! Vrai si aucun palier déclaré n'est fourni ⇒ modulation continue (contrat §2).
|
|
bool isDynamic() const { return m_powerLevels.isEmpty(); }
|
|
|
|
//! Écrit le state \c powerSetpoint (W) via executeAction (repli \c setStateValue pour mock).
|
|
void writeSetpoint(double powerW);
|
|
|
|
//! Lit le state \c currentPowerW du thing cible (0 si absent / non exposé).
|
|
double readCurrentPowerW() const;
|
|
|
|
ThingManager *m_thingManager;
|
|
QString m_id;
|
|
QString m_label;
|
|
QList<int> m_powerLevels; //!< Paliers W déclarés (vide = dynamic).
|
|
int m_maxPowerW;
|
|
int m_priority;
|
|
LoadNeeds m_needs;
|
|
|
|
double m_currentSetpointW = 0; //!< Dernière consigne écrite (W).
|
|
QDateTime m_lastActionAt;
|
|
};
|