Deux correctifs issus de la même campagne (borne .103, banc .75, phaseCount = 3, 2026-08-28). Ils touchent les mêmes fonctions et sortent des mêmes relevés. 1. sessionEnergy — CONSTRUIT, pas câblé ------------------------------------------------------------------------------ La Trydan ne publie pas d'énergie de session. Son compteur ChargeEnergy (0x0BC4) est exact — son intégration recoupe currentPower à mieux d'un pour cent, deux fois de suite — mais il n'est pas conservé : le firmware le remet à zéro à chaque ARRÊT DE CHARGE, câble encore branché. Cycle mesuré : arrêt à t+1 s, remise à zéro à t+8 s, débranchement seulement à t+127 s. Le déclencheur est l'arrêt, pas le débranchement. C'est notre problème et pas celui du firmware : en pilotage par surplus, c'est notre propre arbitrage qui coupe au passage d'un nuage. Le HEMS effacerait donc sa propre mesure plusieurs fois par jour, et une nuit de recharge relue à 7 h vaudrait 0 — soit « rien livré », ce que la condition C1 de LM-1009 interdit d'afficher. accumulerEnergieDeSession() accumule les incréments avant chaque remise à zéro. Dans le plugin et pas dans le moteur : le plugin scrute à 30 s, le moteur à la minute — un incrément effacé entre deux lectures du moteur lui serait invisible. En sortie, le moteur et l'app reçoivent le contrat evcharger standard, sans cas particulier V2C à porter en aval. Même principe que le câblage d'energystorage sur la batterie SunSpec. Trois décisions, détaillées dans AGENTS.md § SESSION ENERGY : - RAZ à l'OUVERTURE de session, jamais à la fermeture. Après le départ de la voiture, sessionEnergy garde le total de la session terminée : la question « combien la nuit a-t-elle livré ? » se pose au matin, voiture déjà partie. Remettre à zéro au débranchement effacerait la réponse au moment précis où on la cherche. - Anti-clignotement STRUCTUREL, pas temporisé. Le débranchement doit être confirmé sur 3 polls RÉUSSIS consécutifs. updateFinished() n'est émis qu'après une séquence de lecture complète : une coupure WiFi GÈLE le compteur d'absences au lieu de le faire avancer. C'est la protection qu'il fallait contre le lien de ce matin (latence 2-4 s, 40 % de perte avant stabilisation). - Persistance sur deux supports. sessionEnergy en state « cached » ; le repère de reprise est la dernière valeur brute de chargeEnergy, elle aussi passée en « cached » — retirer cette propriété ferait repartir de zéro une recharge qui traverse un redémarrage, SANS QUE RIEN NE LE SIGNALE, d'où l'annotation en travers du code. Le drapeau « session ouverte » va dans pluginStorage(), écrit sur transition seulement (flash de la passerelle). Deux limites assumées plutôt que contournées : - Si un débranchement PUIS un rebranchement tiennent entièrement dans une coupure, la nouvelle session hérite du total de la précédente. La borne ne numérote pas ses sessions, et une RAZ de ChargeEnergy ne lève pas l'ambiguïté : elle survient aussi au sein d'une même session. Le choix inverse — repartir de zéro à chaque redémarrage — casserait le cas que ce correctif existe pour couvrir. - L'énergie livrée entre le dernier poll et l'arrêt de charge est perdue, bornée par un intervalle de poll. NON estimée par currentPower × Δt : on n'invente pas une mesure pour combler un trou de mesure. chargeEnergy reste publié à côté, sous son nom exact — sessionEnergy est la grandeur construite, chargeEnergy la mesure brute. Si les deux divergent un jour, on saura laquelle a menti. Seul le displayName change, « Session energy (diagnostic) » étant devenu mensonger. Ceci lève l'interdiction précédente (« ne pas exposer sessionEnergy », cf. evcc issue #28047), mais pas de la façon qu'elle envisageait : elle attendait une validation de fiabilité, la campagne a séparé exactitude (bonne) et conservation (nulle). 2. Power_L1/L2/L3 portent des AMPÈRES ------------------------------------------------------------------------------ La feuille V2C intitule 0x0BD9-0x0BDB « Power_Lx ». Mesuré : consigne 6 A → 5,9-6,0 lus ; consigne 10 A → 9,x-10 lus. Sous 230 V une puissance de phase vaudrait ~1 380 W, pas 5,9. Publiés en Watt, l'app affichait « 5,9 W » là où la borne dit « 5,9 A » — une unité qui ment, même famille que carBatteryLevel publié sous un nom de mesure. States renommés currentL1/L2/L3, unité Ampere. Les UUID ne changent PAS : l'identifiant a toujours porté des ampères, c'est le nom qui mentait. Les noms d'enum RegPower_Lx gardent en revanche l'intitulé du constructeur — c'est ainsi qu'on retrouve l'adresse dans la feuille — avec l'avertissement en commentaire. Bénéfice de bord : le déséquilibre entre phases, que ne donne aucune autre borne du catalogue, utile à la protection de surcharge par phase (LM-1006-1). 3. Observations de campagne consignées ------------------------------------------------------------------------------ - Latence de consigne asymétrique, du bon côté pour la sécurité : montée 6 → 10 A en ~30 s, descente 10 → 6 A en ~7 s. - maxChargingCurrent (consigne, 0x177D) et intensity (effet, 0x0BCD) diffèrent pendant la transition — même couple commandé/réel qu'allocatedW vs mechanism. Ne pas « corriger » l'un avec l'autre. - optimizerSuspended et conflictDetected étaient à True : la borne a son propre optimiseur et le HEMS le suspend. Second commandeur potentiel, à lire en diagnostic quand un écart consigne/effet n'est pas expliqué par la latence. - La ThingClass ne déclare que deux actions, maxChargingCurrent et power : phaseCount est un réglage de câblage, jamais une commande. Compile sans avertissement sous -Werror -Wall -Wextra. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A41CTzMvir1Y5ABPXW8xpB
239 lines
11 KiB
C++
239 lines
11 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
||
#ifndef TRYDANMODBUSMASTER_H
|
||
#define TRYDANMODBUSMASTER_H
|
||
|
||
#include <QObject>
|
||
#include <cstring>
|
||
|
||
/*!
|
||
* \brief Abstract transport interface for the V2C Trydan Modbus connection.
|
||
*
|
||
* Carries all register addresses, float-decode helpers, and the last-polled
|
||
* values. Concrete subclasses provide the physical transport (Modbus TCP for
|
||
* Étape 1, Modbus RTU for Étape 2) without duplicating any of this logic.
|
||
*
|
||
* \b Register-map rules (cf. V2C Trydan_Modbus_TCP, modbus.py):
|
||
* - Every value occupies \b two holding registers encoded as IEEE-754 float32
|
||
* with big-endian byte order and big-endian word order (high word first).
|
||
* - Integer values (ChargeState, Intensity, Dynamic …) are still sent as
|
||
* float32; they must be decoded then rounded — never read as raw uint16.
|
||
* - \b No block read: consecutive addresses (0x0BC2, 0x0BC3 …) overlap their
|
||
* 2-register windows. Each value must be fetched in its own FC3 transaction.
|
||
* - Writes use FC6 (single register, uint16 direct value — NOT float).
|
||
*/
|
||
class TrydanModbusMaster : public QObject
|
||
{
|
||
Q_OBJECT
|
||
|
||
public:
|
||
/*!
|
||
* \brief Holding-register read addresses (FC3, 2 registers each, float32 Big/Big).
|
||
*
|
||
* Source: modbus.py lines _read_register + regenera_float (V2C lib).
|
||
* The intentional overlap (0x0BC2 covers regs 0x0BC2..0x0BC3, 0x0BC3
|
||
* covers 0x0BC3..0x0BC4 …) is why block reads are forbidden.
|
||
*/
|
||
enum ReadRegister : quint16 {
|
||
RegChargeState = 0x0BC2, ///< 0=A(disconnected) 1=B(connected) 2=C(charging)
|
||
RegChargePower = 0x0BC3, ///< W
|
||
RegChargeEnergy = 0x0BC4, ///< kWh, remis à zéro à chaque ARRÊT de charge — source de sessionEnergy, pas sessionEnergy
|
||
RegSlaveError = 0x0BC5, ///< firmware error code (0-10, cf. decodeSlaveError table)
|
||
RegChargeTime = 0x0BC6, ///< s, session duration (confirmed hw fw2.4.6, not yet exposed)
|
||
RegHousePower = 0x0BC8, ///< W, CT clamp (if installed)
|
||
RegPowerFV = 0x0BC9, ///< W, PV production seen by charger (if configured)
|
||
RegPauseState = 0x0BCA, ///< 0=active 1=paused
|
||
RegLock = 0x0BCB, ///< 0=unlocked 1=locked
|
||
RegIntensity = 0x0BCD, ///< A, active charge current
|
||
RegDynamic = 0x0BCE, ///< 0=off 1=internal optimizer running — re-read every poll
|
||
RegMinIntensity = 0x0BD1, ///< A, lower bound
|
||
RegMaxIntensity = 0x0BD2, ///< A, upper bound (firmware-version–dependent, verify on hardware)
|
||
RegPauseDynamic = 0x0BD3, ///< 0=optimizer runs 1=optimizer suspended by HEMS
|
||
// \warning La feuille V2C intitule ces registres « Power_L1/L2/L3 », mais ils
|
||
// portent des AMPÈRES, pas des watts. Mesuré sur borne le 2026-08-28 :
|
||
// consigne 6 A → 5,9-6,0 lus ; consigne 10 A → 9,x-10 lus. Une puissance de
|
||
// phase vaudrait ~1 380 W sous 230 V, pas 5,9. Le nom d'enum garde l'intitulé
|
||
// du constructeur (c'est ainsi qu'on retrouve l'adresse dans la feuille) ; les
|
||
// accesseurs et les states publient la grandeur réelle : currentL1/L2/L3, en A.
|
||
RegPower_L1 = 0x0BD9, ///< A, phase L1 current (confirmed hw fw2.4.6)
|
||
RegPower_L2 = 0x0BDA, ///< A, phase L2 current
|
||
RegPower_L3 = 0x0BDB, ///< A, phase L3 current
|
||
};
|
||
|
||
/*!
|
||
* \brief Write-register addresses (FC6, uint16 raw value — not float).
|
||
*
|
||
* \warning 0x177E (Dynamic) is \b intentionally absent: writing it to 0
|
||
* disables power telemetry (ChargePower goes silent) even though the charger
|
||
* keeps operating. Use PauseDynamic (0x1783) to suppress the internal PID
|
||
* instead. cf. evcc charger/trydan.go and github.com/evcc-io/evcc/issues/28047.
|
||
*/
|
||
enum WriteRegister : quint16 {
|
||
WRegPauseState = 0x177A, ///< setChargingEnabled: 1=pause, 0=active
|
||
WRegLock = 0x177B, ///< mirror of PauseState (always written together)
|
||
WRegIntensity = 0x177D, ///< setMaxChargingCurrent (integer amperes)
|
||
WRegPauseDynamic = 0x1783, ///< 1=suspend internal PID, 0=release
|
||
};
|
||
|
||
// --- Last values from the most recent update() cycle ---
|
||
|
||
/*! \brief ChargeState: 0=A, 1=B, 2=C (IEC 61851) */
|
||
int chargeState() const { return m_chargeState; }
|
||
/*! \brief ChargePower in watts */
|
||
float chargePower() const { return m_chargePower; }
|
||
/*!
|
||
* \brief Compteur d'énergie brut de la borne, en kWh.
|
||
*
|
||
* \warning Ce n'est PAS l'énergie de session : le firmware le remet à zéro à
|
||
* chaque ARRÊT de charge, câble toujours branché (mesuré le 2026-08-28 : remise
|
||
* à zéro 8 s après l'arrêt, débranchement seulement 119 s plus tard). Le plugin
|
||
* en accumule les incréments pour construire sessionEnergy — cf.
|
||
* IntegrationPluginV2c::accumulerEnergieDeSession().
|
||
*/
|
||
float chargeEnergy() const { return m_chargeEnergy; }
|
||
/*! \brief Firmware error code */
|
||
int slaveError() const { return m_slaveError; }
|
||
/*! \brief House CT-clamp power in watts */
|
||
float housePower() const { return m_housePower; }
|
||
/*! \brief PV production seen by the charger, in watts */
|
||
float powerFV() const { return m_powerFV; }
|
||
/*! \brief PauseState register: 0=active, 1=paused */
|
||
int pauseState() const { return m_pauseState; }
|
||
/*! \brief Lock register: 0=unlocked, 1=locked */
|
||
int lock() const { return m_lock; }
|
||
/*! \brief Current charge current in amperes */
|
||
int intensity() const { return m_intensity; }
|
||
/*! \brief Dynamic register: 0=no internal optimizer, 1=optimizer active */
|
||
int dynamicMode() const { return m_dynamic; }
|
||
/*! \brief Configured minimum intensity (init-time, usually 6 A) */
|
||
int minIntensity() const { return m_minIntensity; }
|
||
/*! \brief Configured maximum intensity (init-time, firmware-version–dependent) */
|
||
int maxIntensity() const { return m_maxIntensity; }
|
||
/*! \brief PauseDynamic: 0=optimizer runs, 1=HEMS has suspended it */
|
||
int pauseDynamic() const { return m_pauseDynamic; }
|
||
/*! \brief Phase L1 current in amperes (diagnostic, 0 if not available) */
|
||
float currentL1() const { return m_currentL1; }
|
||
/*! \brief Phase L2 current in amperes (diagnostic, 0 if not available) */
|
||
float currentL2() const { return m_currentL2; }
|
||
/*! \brief Phase L3 current in amperes (diagnostic, 0 if not available) */
|
||
float currentL3() const { return m_currentL3; }
|
||
|
||
bool reachable() const { return m_reachable; }
|
||
|
||
// --- Transport operations (pure virtual) ---
|
||
|
||
/*! \brief Establish the physical connection to the charger. */
|
||
virtual void connectDevice() = 0;
|
||
/*! \brief Drop the physical connection. */
|
||
virtual void disconnectDevice() = 0;
|
||
/*!
|
||
* \brief Read init-time registers (MinIntensity, MaxIntensity).
|
||
* \return false if a read is already in progress.
|
||
*/
|
||
virtual bool initialize() = 0;
|
||
/*!
|
||
* \brief Read all poll-cycle registers sequentially.
|
||
* Emits updateFinished() on completion (success or error).
|
||
* \return false if a poll is already in progress.
|
||
*/
|
||
virtual bool update() = 0;
|
||
|
||
/*!
|
||
* \brief Write PauseState register (FC6).
|
||
* Always pair with writeLock(); the two must stay consistent.
|
||
* cf. evcc trydan.go Enable().
|
||
* \param value 0=active 1=paused
|
||
*/
|
||
virtual void writePauseState(quint16 value) = 0;
|
||
/*!
|
||
* \brief Write Lock register (FC6).
|
||
* \param value 0=unlocked 1=locked
|
||
*/
|
||
virtual void writeLock(quint16 value) = 0;
|
||
/*!
|
||
* \brief Write Intensity register (FC6, integer amperes).
|
||
* Caller must clamp to [minIntensity(), maxIntensity()] before calling.
|
||
*/
|
||
virtual void writeIntensity(quint16 amps) = 0;
|
||
/*!
|
||
* \brief Write PauseDynamic register (FC6).
|
||
* \param value 1=suspend internal optimizer PID, 0=release
|
||
*/
|
||
virtual void writePauseDynamic(quint16 value) = 0;
|
||
|
||
signals:
|
||
/*! \brief Emitted when the transport-level connection state changes. */
|
||
void reachableChanged(bool reachable);
|
||
/*!
|
||
* \brief Emitted once initialize() finishes (success or failure).
|
||
* On success the init-time registers (MinIntensity, MaxIntensity) are valid.
|
||
*/
|
||
void initializationFinished(bool success);
|
||
/*!
|
||
* \brief Emitted once each update() cycle completes.
|
||
* All accessor methods reflect the newly read values when this fires.
|
||
*/
|
||
void updateFinished();
|
||
/*!
|
||
* \brief Emitted after each write operation completes.
|
||
* \param address The write register address (WRegPauseState, WRegLock, …).
|
||
* Action handlers must filter by address to avoid processing
|
||
* background writes (e.g. PauseDynamic from conflict management)
|
||
* as if they were the writes triggered by the action itself.
|
||
*/
|
||
void writeCompleted(quint16 address, bool success);
|
||
|
||
protected:
|
||
explicit TrydanModbusMaster(QObject *parent = nullptr);
|
||
|
||
/*!
|
||
* \brief Decode two holding-register words into a float32 (Big/Big).
|
||
*
|
||
* The V2C Trydan uses big-endian byte order AND big-endian word order:
|
||
* the high 16-bit word arrives first on the wire, forming the most-significant
|
||
* half of the 32-bit pattern. This matches pymodbus BinaryPayloadDecoder
|
||
* with byteorder=Endian.Big, wordorder=Endian.Big.
|
||
* cf. modbus.py regenera_float()
|
||
*
|
||
* \param high First register received (most-significant 16 bits of float32)
|
||
* \param low Second register received (least-significant 16 bits)
|
||
*/
|
||
static float decodeFloat32BB(quint16 high, quint16 low);
|
||
|
||
/*!
|
||
* \brief Decode an integer value stored as float32 Big/Big.
|
||
*
|
||
* All "integer" registers (ChargeState, Intensity, Dynamic …) are still
|
||
* encoded as float32. Round to nearest int after decode.
|
||
* cf. modbus.py: int(round(regenera_float(regs)))
|
||
*/
|
||
static int decodeIntFromFloat32(quint16 high, quint16 low);
|
||
|
||
/*!
|
||
* \brief Update reachability and emit reachableChanged() on transition.
|
||
*/
|
||
void setReachable(bool reachable);
|
||
|
||
// Cached register values (written by concrete subclasses during polls)
|
||
int m_chargeState = 0;
|
||
float m_chargePower = 0.0f;
|
||
float m_chargeEnergy = 0.0f;
|
||
int m_slaveError = 0;
|
||
float m_housePower = 0.0f;
|
||
float m_powerFV = 0.0f;
|
||
int m_pauseState = 0;
|
||
int m_lock = 0;
|
||
int m_intensity = 0;
|
||
int m_dynamic = 0;
|
||
int m_minIntensity = 6;
|
||
int m_maxIntensity = 32;
|
||
int m_pauseDynamic = 0;
|
||
float m_currentL1 = 0.0f;
|
||
float m_currentL2 = 0.0f;
|
||
float m_currentL3 = 0.0f;
|
||
|
||
bool m_reachable = false;
|
||
};
|
||
|
||
#endif // TRYDANMODBUSMASTER_H
|