- 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>
225 lines
9.8 KiB
C++
225 lines
9.8 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 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-version–dependent, 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-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 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
|