etm-powersync-plugins-modbus/v2c/trydanmodbusmaster.h
Patrick Schurig 189d58d9ce v2c: hw-validated improvements (Trydan fw2.4.6)
- Decode SlaveError (reg 0x0BC5) with full table (codes 0-10) into new
  slaveErrorMessage state; code 04 (WiFi reconnect) also surfaces in
  statusMessage for immediate SAV visibility.
- Add L1/L2/L3 phase powers (regs 0x0BD9-0x0BDB, confirmed hw) as
  diagnostic states polled every 30 s.
- Add RegChargeTime (0x0BC6) to enum for completeness (not yet exposed).
- Collapse debian/changelog to single 1.15.0+etm1 entry (new plugin).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 09:26:53 +02:00

225 lines
9.8 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 session — diagnostic only, NOT 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-versiondependent, verify on hardware)
RegPauseDynamic = 0x0BD3, ///< 0=optimizer runs 1=optimizer suspended by HEMS
RegPower_L1 = 0x0BD9, ///< W, phase L1 power (confirmed hw fw2.4.6)
RegPower_L2 = 0x0BDA, ///< W, phase L2 power
RegPower_L3 = 0x0BDB, ///< W, phase L3 power
};
/*!
* \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 Session energy in kWh (diagnostic only — firmware reliability unvalidated) */
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-versiondependent) */
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 power in watts (diagnostic, 0 if not available) */
float powerL1() const { return m_powerL1; }
/*! \brief Phase L2 power in watts (diagnostic, 0 if not available) */
float powerL2() const { return m_powerL2; }
/*! \brief Phase L3 power in watts (diagnostic, 0 if not available) */
float powerL3() const { return m_powerL3; }
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_powerL1 = 0.0f;
float m_powerL2 = 0.0f;
float m_powerL3 = 0.0f;
bool m_reachable = false;
};
#endif // TRYDANMODBUSMASTER_H