etm-powersync-plugins-modbus/v2c/trydanmodbusmaster.h
Patrick Schurig 14f07cc638 feat(v2c): add Modbus transport abstraction + TCP implementation
Architecture choice: separate the register map / business logic from the
physical transport.  TrydanModbusMaster (abstract QObject) holds:
  - the complete ReadRegister / WriteRegister enums with addresses
  - float32 Big/Big decode helpers (memcpy pattern, matches pymodbus
    BinaryPayloadDecoder byteorder=Endian.Big wordorder=Endian.Big)
  - all last-polled cached values accessible via simple getters

TrydanModbusTcpMaster (concrete, Étape 1) wraps libnymea-modbus
ModbusTcpMaster.  Each register read is a distinct FC3(addr, 2)
transaction via a recursive runReadSequence/doNextRead pattern —
never a block read, because the V2C firmware's register windows
overlap (0x0BC2 and 0x0BC3 each span 2 registers, so a range read
starting at 0x0BC2 for ≥3 registers returns garbage for the second
value).  Writes use FC6 (single uint16, not float).

writeCompleted(quint16 address, bool success) carries the originating
register address so that concurrent background writes (e.g. PauseDynamic
from the conflict manager firing during an action) do not interfere with
action handlers waiting on a specific register's acknowledgement.

0x177E (Dynamic) is intentionally absent from WriteRegister: writing it
to 0 silences ChargePower telemetry even though the charger keeps running.
cf. evcc charger/trydan.go and github.com/evcc-io/evcc/issues/28047.

Étape 2 (RTU) will add TrydanModbusRtuMaster on the same interface;
no changes to plugin or register logic will be required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:49:11 +02:00

212 lines
8.9 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
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
};
/*!
* \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; }
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;
bool m_reachable = false;
};
#endif // TRYDANMODBUSMASTER_H