LM-1105/LM-1106 implantés. measuredW disparaît, sans alias. ILoadAdapter::deviceMeasurement() — PURE VIRTUELLE, comme runtimeView() et pour la même raison : un mécanisme ajouté qui hériterait d'un « non mesurable » par défaut sortirait de la mesure sans bruit, et son écran afficherait « pas mesurable » avec l'autorité d'un constat. Le compilateur pose la question. Le type porte une CAPACITÉ, pas une valeur nulle : `available` répond à « cette charge est-elle mesurable », question posée à la ThingClass, jamais à la valeur du moment. Un routeur au palier 0 mesure 0 W et reste mesurable ; sans ce booléen les deux seraient indistinguables. attachMeasurements() résout meter → device → none. Le compteur explicite garde la priorité, et pas d'abord parce qu'il mesure mieux : un réglage sans effet est pire que pas de réglage. La priorité porte sur la SOURCE, pas sur la valeur — compteur déclaré injoignable, on retombe sur l'appareil, parce que se taire perdrait une mesure qu'on a et que le silence se lirait « non mesurable ». Sous `none`, powerW est OMIS. La puissance nominale reste lisible dans mechanism.*, qui dit d'où elle vient ; la faire passer pour une mesure produirait un écart commandé↔mesuré identiquement nul à tous les cycles — un « tout concorde » permanent, corollaire LM-104. DEUX AFFINEMENTS IMPOSÉS PAR L'IMPLANTATION, tous deux de modèle : LM-1105-a — une mesure PARTIELLE n'est pas une mesure. Là où plusieurs Things portent la charge, il faut qu'ils la portent TOUS : trois relais dont un seul mesure annonceraient 500 W là où 1 500 W coulent, et l'écart enverrait chercher une panne inexistante. À ne pas confondre avec LoadTelemetry::currentPowerW, qui accepte « au moins un » et retombe sur le nominal — celui-là est le juge runtime du budget et doit rendre un nombre à chaque cycle. Les fusionner rouvrirait la correction B. LM-1105-b — la règle présuppose que le Thing piloté est TRAVERSÉ par la puissance. Un relais de relay-router commute la charge, donc sa puissance EST celle de la charge ; un contact SG-Ready est un contact de SIGNAL — la PAC est alimentée ailleurs et un contact qui mesurerait quelque chose mesurerait sa propre bobine. sg-ready est donc `none` par CONSTRUCTION, même sur des relais qui mesurent — c'est le cas du banc — et c'est le seul mécanisme pour lequel meterThingId reste la seule voie. RelayRouter : la capacité se juge sur la ThingClass de TOUS les relais déclarés, la valeur se lit sur les relais FERMÉS. Jugée sur les relais actifs, la source vaudrait `device` au palier 1 et `none` au palier 0 — un bloc de mesure qui apparaît et disparaît au rythme du thermostat. VÉRIFIÉ ÉCHOUANT ainsi. testMeasurementSourceIsPublished : les trois sources sans une ligne de config de compteur, le palier 0 qui ne fait pas basculer la source, la PAC en `none` alors que ses contacts mesurent, la disparition de measuredW, puis les deux règles de priorité — le compteur explicite l'emporte (1 234 contre 2 900), un compteur injoignable fait retomber sur l'appareil. Ne touche PAS ce que l'arbitre lit : LM-1101 tient, LoadTelemetry::currentPowerW est inchangé, et le recrédit anti-clignotement n'a jamais transité par meterThingId. amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation 30/30, charging 16/16, loadmodel 18/18, spotmarket 7/7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
249 lines
13 KiB
C++
249 lines
13 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 <QHash>
|
|
#include <QString>
|
|
#include <QSet>
|
|
#include "iloadadapter.h"
|
|
|
|
class ThingActionInfo;
|
|
|
|
class Thing;
|
|
class ThingManager;
|
|
|
|
/*!
|
|
* \brief Adaptateur SG-Ready (PAC) — interface "sg-ready", action \c kind:State.
|
|
*
|
|
* Pilote une pompe à chaleur via 2 contacts SG-Ready (encodage 2 bits → 4 états NORMÉS) :
|
|
* 1 = blocage (EVU-Sperre) · 2 = normal (mains off : la PAC décide)
|
|
* 3 = recommandation (surplus) · 4 = forcé (boost)
|
|
*
|
|
* Les 4 états ne sont PAS des paliers de puissance : ils sont qualitatifs, la PAC les
|
|
* interprète selon SA logique. \c m_stateRelays[état] = ThingIds powerswitch à mettre ON
|
|
* pour cet état (encodage câblé par l'installateur ; les autres relais sont OFF).
|
|
*
|
|
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
|
|
* \invariant applyAction() applique le verrou \c minStateHoldS (protection court-cycling
|
|
* compresseur) SAUF si \c action.force == true (réservé L2 watchdog → état 2).
|
|
* \invariant L'état est écrêté à l'ensemble \c m_states avant envoi matériel.
|
|
* \invariant Seul le kind State est traité ; les autres kinds retournent sans effet.
|
|
*
|
|
* \par Contrat d'atomicité (transport déporté Shelly/Modbus à venir)
|
|
* Une transition d'état commute parfois 2 relais (ex. 2→4 : 00→11). Les contacts
|
|
* doivent être écrits **aussi atomiquement que possible**, et l'ORDRE de commutation
|
|
* doit éviter tout **état actif parasite** : on passe par le transitoire le plus DOUX
|
|
* (neutre = état 2, sinon recommandation = état 3) plutôt que par blocage (1) ou forcé
|
|
* (4). \c applyStateRelays() choisit cet ordre. En GPIO local le transitoire dure des µs,
|
|
* mais l'intention est portée par l'adaptateur pour rester correcte sur un bus lent.
|
|
*/
|
|
class SgReadyAdapter : public QObject, public ILoadAdapter
|
|
{
|
|
Q_OBJECT
|
|
public:
|
|
/*!
|
|
* \brief Constructeur.
|
|
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds.
|
|
* \param id Identifiant logique de la charge.
|
|
* \param label Nom lisible (logs, app).
|
|
* \param stateRelays état → liste de ThingIds powerswitch ON (encodage 2 bits SG-Ready).
|
|
* \param estimatedPowerW Puissance estimée (W) par état (déclaré installateur, approx.).
|
|
* \param minStateHoldS Durée minimale de maintien d'état (s) — protection court-cycling.
|
|
* \param priority Rang dans le waterfall (protocole §5 : valeur plus BASSE = servi en premier).
|
|
* \param parent Propriétaire Qt.
|
|
*/
|
|
explicit SgReadyAdapter(ThingManager *thingManager,
|
|
const QString &id,
|
|
const QString &label,
|
|
const QHash<int, QList<QString>> &stateRelays,
|
|
const QHash<int, double> &estimatedPowerW,
|
|
int minStateHoldS,
|
|
int priority,
|
|
QObject *parent = nullptr);
|
|
|
|
LoadDescriptor descriptor() const override;
|
|
|
|
/*!
|
|
* \brief Télémétrie runtime. \c currentPowerW = puissance ALLOUÉE de l'état courant
|
|
* (\c declared.estimatedPowerW : 0 pour états 1/2, P3/P4 pour 3/4). C'est la base du
|
|
* recrédit budget — PAS la conso mesurée de la PAC (l'état 2 autonome est déjà au
|
|
* compteur, invariant 8 : la recréditer double-compterait).
|
|
* \return LoadTelemetry ; \c available toujours vrai (l'encodage relais ne dépend pas
|
|
* d'une liaison montante), \c lastActionAt nul tant qu'aucune transition n'a eu lieu.
|
|
*/
|
|
LoadTelemetry telemetry() const override;
|
|
|
|
/*!
|
|
* \brief Construit l'entrée loads[] §5 (adapter="sg-ready").
|
|
* \param now Temps de cycle (\c ctx.timestamp) — source unique de la fenêtre minState/maxState.
|
|
* \return LoadContext incluant \c telemetry.state et la fenêtre \c minState/\c maxState
|
|
* évaluée à \p now : c'est sur elle que le scheduler clampe sa cible.
|
|
*/
|
|
LoadContext toLoadContext(const QDateTime &now) const override;
|
|
|
|
/*!
|
|
* \brief Applique un changement d'état SG-Ready (2 relais, transition atomique-douce).
|
|
* \param action LoadAction de kind State. Autres kinds : retour sans effet.
|
|
* \param now Temps de cycle — MÊME source que toLoadContext() (verrou + lastSwitch).
|
|
* \return L'action après écrêtage (state borné à l'ensemble déclaré).
|
|
*/
|
|
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
|
|
|
//! \brief Sans effet : la PAC du banc est codée en dur, pas construite depuis
|
|
//! \c LoadConfig (à basculer avec la couche config).
|
|
//! \param label Nouveau libellé — repris DÈS ce cycle, il nomme la PAC au journal.
|
|
//! \param priority Ignoré. \param needs Ignoré.
|
|
void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) override
|
|
{ m_label = label; Q_UNUSED(priority) Q_UNUSED(needs) }
|
|
|
|
//! \brief Sans effet : la PAC SG-Ready n'implémente pas encore l'échelle de défaut ECS-410.
|
|
//! La généralisation est portée par **ECS-414** (lot de mise en configuration
|
|
//! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide.
|
|
/*!
|
|
* \brief Lève le verrou de défaut (ECS-410/414, RPC \c ClearLoadFault).
|
|
* \note L'état réel est RELU depuis les contacts, jamais supposé : après un défaut, le
|
|
* motif 2 bits peut être valide mais non voulu.
|
|
*/
|
|
bool clearFault() override;
|
|
|
|
//! \return Vrai si la PAC est en défaut (plus aucune commande émise).
|
|
bool faulted() const { return m_faulted; }
|
|
|
|
/*!
|
|
* \brief Vue runtime pour la télémétrie RPC : état normé, estimation, défaut, verrou.
|
|
* \param now Temps de cycle — même source que \c lockWindow(), donc les secondes annoncées
|
|
* sont celles du verrou qui a réellement borné la décision.
|
|
* \return \c mechanismKind "sgReady" ; \c mechanism \c {state, estimatedPowerW}.
|
|
*/
|
|
LoadRuntimeView runtimeView(const QDateTime &now) const override;
|
|
|
|
/*!
|
|
* \brief Toujours « non mesurable » — et c'est une propriété du MÉCANISME (§11 / LM-1105).
|
|
*
|
|
* \return \c available toujours faux.
|
|
*
|
|
* \note Ce n'est pas un « pas encore » : un contact SG-Ready est un contact de **SIGNAL**.
|
|
* La PAC est alimentée ailleurs et son courant ne traverse jamais K1/K2 — un contact qui
|
|
* mesurerait quelque chose mesurerait **sa propre bobine**. La règle générale
|
|
* « le moteur lit l'appareil qu'il pilote » présuppose que l'appareil piloté est
|
|
* TRAVERSÉ par la puissance de la charge ; ici il ne l'est pas.
|
|
* \note C'est donc le seul mécanisme pour lequel \c meterThingId reste la **seule** voie
|
|
* vers une mesure, et cela doit se lire à l'écran plutôt que se deviner : d'où
|
|
* \c source: "none" publié, plutôt qu'une clé absente (LM-1106).
|
|
* \warning Ne PAS publier \c m_estimatedPowerW ici. C'est une puissance **déclarée** —
|
|
* le juge du recrédit budget, invariant 8 — et la faire passer pour une mesure
|
|
* produirait un écart commandé↔mesuré identiquement nul à tous les cycles : un « tout
|
|
* concorde » permanent, corollaire LM-104.
|
|
*/
|
|
LoadMeasurement deviceMeasurement() const override
|
|
{ return LoadMeasurement{}; }
|
|
|
|
/*!
|
|
* \brief État sûr de la PAC : **état 2** (normal, mains off) — JAMAIS l'état 1 (blocage).
|
|
* \param now Temps de cycle ; l'action passe en \c force = true (bypass minStateHold).
|
|
* \note Couper une PAC en la bloquant serait une régression de sécurité, pas une mise en
|
|
* sécurité (\c docs/SAFETY.md). C'est le même état que le repli du mode dégradé L2.
|
|
*/
|
|
void applySafeState(const QDateTime &now) override;
|
|
|
|
/*!
|
|
* \brief État SG-Ready courant (1-4).
|
|
* \return État commandé. À la CONSTRUCTION il est RELU depuis les contacts (ECS-411), pas
|
|
* supposé : un adaptateur neuf qui se croirait en état 2 alors que la PAC tourne en forcé
|
|
* annoncerait une puissance nulle qui circule, et — pire — refuserait comme idempotente
|
|
* une remise à l'état 2 qui n'a jamais été écrite (LM-104). Reflète le COMMANDÉ : la PAC
|
|
* reste seule juge de son fonctionnement.
|
|
*/
|
|
int currentState() const { return m_currentState; }
|
|
|
|
private:
|
|
/*!
|
|
* \brief Fenêtre d'états autorisée à \p now par le verrou minStateHold (symétrique).
|
|
* \param now Temps de cycle (\c ctx.timestamp) — jamais l'horloge.
|
|
* \param[out] minState Borne basse : \c m_currentState si \c minStateHold non écoulé
|
|
* (gel total), sinon le plus petit état déclaré.
|
|
* \param[out] maxState Borne haute : \c m_currentState si \c minStateHold non écoulé
|
|
* (gel total), sinon le plus grand état déclaré.
|
|
*/
|
|
void lockWindow(const QDateTime &now, int &minState, int &maxState) const;
|
|
|
|
bool lockActive(int newState, const QDateTime &now) const;
|
|
|
|
/*!
|
|
* \brief Amène les contacts à l'encodage de \p toState depuis un motif RÉEL quelconque.
|
|
* \param currentOn Ensemble des relais actuellement fermés — pas un index d'état.
|
|
* \param toState État visé.
|
|
* \note Prend un ENSEMBLE et non un état, parce que le chemin de REPLI (ECS-414) part
|
|
* d'un motif qui peut être valide mais non voulu — voire hors table — après une
|
|
* écriture partiellement échouée. Le contrat d'atomicité (\c transientHarm) doit
|
|
* protéger le repli exactement comme le chemin aller : sans quoi la récupération
|
|
* traverserait le blocage et serait plus dangereuse que la panne qu'elle corrige.
|
|
*/
|
|
void applyStateRelays(const QSet<QString> ¤tOn, int toState);
|
|
|
|
//! \return Ensemble des relais RÉELLEMENT fermés, lu sur les Things.
|
|
QSet<QString> readActualOn() const;
|
|
|
|
//! Écrit un contact et suit son acquittement (ECS-414, modèle d'ECS-410).
|
|
void writeRelay(const QString &thingId, bool on);
|
|
//! Verdict quand toutes les écritures d'une étape sont acquittées ; fait avancer l'échelle.
|
|
void settleTransition();
|
|
|
|
//! Barreaux de l'échelle. Le plancher d'une PAC est l'ÉTAT 2, jamais « contacts ouverts ».
|
|
enum Phase { PhaseNominale, PhaseRepli, PhasePlancher, PhaseDefaut };
|
|
|
|
//! Rang de nocivité d'un état comme TRANSITOIRE (2 neutre < 3 reco < 1 blocage < 4 forcé).
|
|
static int transientHarm(int state);
|
|
|
|
//! État correspondant à un ensemble de relais ON (-1 si aucun état ne correspond).
|
|
int stateForRelays(const QList<QString> &onRelays) const;
|
|
|
|
/*!
|
|
* \brief Déduit l'état courant du motif RÉEL des contacts, au démarrage (ECS-411).
|
|
* \return État dont l'encodage correspond aux contacts fermés ; à défaut — encodage
|
|
* partiel, motif non déclaré — l'état déclaré de plus forte puissance estimée, jamais
|
|
* moins que ce qui peut circuler.
|
|
* \note Publie le relevé contact par contact AVEC l'issue, y compris quand l'issue est
|
|
* l'état neutre (ECS-411-b) : c'est précisément l'état qu'on vient vérifier après un
|
|
* démarrage, et s'en taire rendrait « contacts lus, tous ouverts » indistinguable de
|
|
* « reprise non exécutée ». Un contact injoignable passe l'issue en avertissement.
|
|
*/
|
|
int deduceStateFromRelays() const;
|
|
|
|
QSet<QString> allRelays() const;
|
|
|
|
ThingManager *m_thingManager;
|
|
QString m_id;
|
|
QString m_label;
|
|
QHash<int, QList<QString>> m_stateRelays; //!< état → ThingIds ON.
|
|
QHash<int, double> m_estimatedPowerW; //!< état → W estimés (déclaré).
|
|
QList<int> m_states; //!< États déclarés triés croissants.
|
|
int m_minStateHoldS;
|
|
int m_priority;
|
|
|
|
//! État commandé. Initialisé à 2 (neutre) puis RELU depuis les contacts par le
|
|
//! constructeur (ECS-411) : la valeur littérale n'est qu'un défaut de sécurité pour le cas
|
|
//! où l'encodage est inutilisable et où aucune commande ne sera jamais émise.
|
|
int m_currentState = 2;
|
|
|
|
// --- ECS-414 : suivi asynchrone des écritures (modèle d'ECS-410) ---------------------
|
|
int m_pending = 0;
|
|
bool m_writeFailed = false;
|
|
int m_statePrev = 2;
|
|
int m_stateTarget = 2;
|
|
Phase m_phase = PhaseNominale;
|
|
bool m_faulted = false;
|
|
//! Faux si l'encodage ne permet pas d'exprimer l'état 2 — repli sûr du mode dégradé L2.
|
|
//! L'adaptateur refuse alors TOUTE commande, et le dit (ECS-110 : jamais un Q_ASSERT,
|
|
//! qui disparaît du binaire release).
|
|
bool m_usable = true;
|
|
//! Dernière commutation. NUL = « on ne sait pas quand » (démarrage, reconstruction), ce qui
|
|
//! ARME le verrou pour sa durée configurée — pas l'inverse (ECS-412). L'armement est
|
|
//! transitoire : \c applyAction() estampille au premier \c now reçu.
|
|
QDateTime m_lastSwitch;
|
|
QDateTime m_lastActionAt;
|
|
};
|