// SPDX-License-Identifier: GPL-3.0-or-later #ifndef TRYDANMODBUSMASTER_H #define TRYDANMODBUSMASTER_H #include #include /*! * \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