diff --git a/v2c/trydanmodbusmaster.cpp b/v2c/trydanmodbusmaster.cpp new file mode 100644 index 0000000..1a87f50 --- /dev/null +++ b/v2c/trydanmodbusmaster.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "trydanmodbusmaster.h" +#include + +TrydanModbusMaster::TrydanModbusMaster(QObject *parent) + : QObject(parent) +{} + +float TrydanModbusMaster::decodeFloat32BB(quint16 high, quint16 low) +{ + // High word is the most-significant 16 bits (Big word order). + // Within each word bytes are already in big-endian order on the wire. + // This mirrors pymodbus BinaryPayloadDecoder(byteorder=Endian.Big, wordorder=Endian.Big). + quint32 raw = (static_cast(high) << 16) | static_cast(low); + float result; + std::memcpy(&result, &raw, sizeof(float)); + return result; +} + +int TrydanModbusMaster::decodeIntFromFloat32(quint16 high, quint16 low) +{ + return static_cast(qRound(decodeFloat32BB(high, low))); +} + +void TrydanModbusMaster::setReachable(bool reachable) +{ + if (m_reachable == reachable) + return; + m_reachable = reachable; + emit reachableChanged(reachable); +} diff --git a/v2c/trydanmodbusmaster.h b/v2c/trydanmodbusmaster.h new file mode 100644 index 0000000..1220995 --- /dev/null +++ b/v2c/trydanmodbusmaster.h @@ -0,0 +1,211 @@ +// 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 + 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 + }; + + /*! + * \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; } + + 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 diff --git a/v2c/trydanmodbustcpmaster.cpp b/v2c/trydanmodbustcpmaster.cpp new file mode 100644 index 0000000..9fbd9de --- /dev/null +++ b/v2c/trydanmodbustcpmaster.cpp @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "trydanmodbustcpmaster.h" + +#include + +#include + +#include "extern-plugininfo.h" + +TrydanModbusTcpMaster::TrydanModbusTcpMaster(const QHostAddress &address, + quint16 port, + quint16 slaveId, + QObject *parent) + : TrydanModbusMaster(parent), + m_slaveId(slaveId) +{ + m_modbusTcpMaster = new ModbusTcpMaster(address, port, this); + m_modbusTcpMaster->setNumberOfRetries(1); + m_modbusTcpMaster->setTimeout(2000); + + connect(m_modbusTcpMaster, &ModbusTcpMaster::connectionStateChanged, + this, &TrydanModbusTcpMaster::onConnectionStateChanged); +} + +TrydanModbusTcpMaster::~TrydanModbusTcpMaster() +{ + // m_modbusTcpMaster is a child QObject and will be deleted automatically. +} + +ModbusTcpMaster *TrydanModbusTcpMaster::modbusTcpMaster() const +{ + return m_modbusTcpMaster; +} + +void TrydanModbusTcpMaster::setHostAddress(const QHostAddress &address) +{ + m_modbusTcpMaster->setHostAddress(address); +} + +void TrydanModbusTcpMaster::connectDevice() +{ + m_modbusTcpMaster->connectDevice(); +} + +void TrydanModbusTcpMaster::disconnectDevice() +{ + m_modbusTcpMaster->disconnectDevice(); + setReachable(false); +} + +bool TrydanModbusTcpMaster::initialize() +{ + if (!m_modbusTcpMaster->connected() || m_initializing) + return false; + + m_initializing = true; + runReadSequence(buildInitSteps(), [this](bool ok) { + m_initializing = false; + if (ok) { + // Clamp to safe IEC 61851 bounds in case firmware reports unusual values. + m_minIntensity = qMax(6, m_minIntensity); + m_maxIntensity = qMin(32, m_maxIntensity); + } + emit initializationFinished(ok); + }); + return true; +} + +bool TrydanModbusTcpMaster::update() +{ + if (!m_modbusTcpMaster->connected() || m_updating) + return false; + + m_updating = true; + runReadSequence(buildPollSteps(), [this](bool ok) { + m_updating = false; + if (ok) { + m_errorCount = 0; + setReachable(true); + emit updateFinished(); + } else { + handleError(); + } + }); + return true; +} + +void TrydanModbusTcpMaster::writePauseState(quint16 value) +{ + writeFC6(WRegPauseState, value, + [this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); }); +} + +void TrydanModbusTcpMaster::writeLock(quint16 value) +{ + writeFC6(WRegLock, value, + [this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); }); +} + +void TrydanModbusTcpMaster::writeIntensity(quint16 amps) +{ + writeFC6(WRegIntensity, amps, + [this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); }); +} + +void TrydanModbusTcpMaster::writePauseDynamic(quint16 value) +{ + // PauseDynamic is also emitted via writeCompleted so the plugin can update + // the optimizerSuspended state if needed; action handlers must filter by address. + writeFC6(WRegPauseDynamic, value, + [this](quint16 addr, bool ok) { emit writeCompleted(addr, ok); }); +} + +// --- Private implementation --- + +QList TrydanModbusTcpMaster::buildInitSteps() +{ + return { + { RegMinIntensity, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_minIntensity = decodeIntFromFloat32(h, l); + }}, + { RegMaxIntensity, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_maxIntensity = decodeIntFromFloat32(h, l); + }}, + }; +} + +QList TrydanModbusTcpMaster::buildPollSteps() +{ + // Each register pair is a separate FC3 transaction (2 registers, float32 Big/Big). + // Order matters: Dynamic must always be read (last) to reflect the most current + // state before the plugin applies the conflict-management logic. + return { + { RegChargeState, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_chargeState = decodeIntFromFloat32(h, l); + }}, + { RegChargePower, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_chargePower = decodeFloat32BB(h, l); + }}, + { RegChargeEnergy, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_chargeEnergy = decodeFloat32BB(h, l); + }}, + { RegSlaveError, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_slaveError = decodeIntFromFloat32(h, l); + }}, + { RegHousePower, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_housePower = decodeFloat32BB(h, l); + }}, + { RegPowerFV, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_powerFV = decodeFloat32BB(h, l); + }}, + { RegPauseState, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_pauseState = decodeIntFromFloat32(h, l); + }}, + { RegLock, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_lock = decodeIntFromFloat32(h, l); + }}, + { RegIntensity, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_intensity = decodeIntFromFloat32(h, l); + }}, + { RegDynamic, [this](bool ok, quint16 h, quint16 l) { + // Critical: must be read every poll — the V2C app can toggle this at any time. + if (ok) m_dynamic = decodeIntFromFloat32(h, l); + }}, + { RegPauseDynamic, [this](bool ok, quint16 h, quint16 l) { + if (ok) m_pauseDynamic = decodeIntFromFloat32(h, l); + }}, + }; +} + +void TrydanModbusTcpMaster::runReadSequence(QList steps, + std::function onDone) +{ + doNextRead(std::move(steps), 0, std::move(onDone)); +} + +void TrydanModbusTcpMaster::doNextRead(QList steps, + int index, + std::function onDone) +{ + if (index >= steps.count()) { + onDone(true); + return; + } + + const quint16 address = steps.at(index).first; + const ReadCallback callback = steps.at(index).second; + + // Individual FC3 read of exactly 2 registers (one float32 value). + // Never read a range spanning multiple values — overlapping windows would + // return garbage for addresses beyond the first. cf. modbus.py _read_register. + QModbusReply *reply = m_modbusTcpMaster->sendReadRequest( + QModbusDataUnit(QModbusDataUnit::HoldingRegisters, address, 2), + static_cast(m_slaveId)); + + if (!reply) { + qCWarning(dcV2c()) << "TrydanModbusTcpMaster: sendReadRequest returned null for reg" << Qt::hex << address; + onDone(false); + return; + } + + connect(reply, &QModbusReply::finished, this, + [this, reply, steps, index, callback, onDone]() mutable { + reply->deleteLater(); + + if (reply->error() != QModbusDevice::NoError) { + qCWarning(dcV2c()) << "TrydanModbusTcpMaster: read error for reg" + << Qt::hex << steps.at(index).first + << ":" << reply->errorString(); + callback(false, 0, 0); + onDone(false); + return; + } + + const auto values = reply->result().values(); + callback(true, values.value(0), values.value(1)); + doNextRead(std::move(steps), index + 1, std::move(onDone)); + }); +} + +void TrydanModbusTcpMaster::writeFC6(quint16 address, quint16 value, + std::function callback) +{ + // FC6 = Write Single Register; QModbusTcpClient uses FC6 for single-word writes. + QModbusDataUnit unit(QModbusDataUnit::HoldingRegisters, address, 1); + unit.setValue(0, value); + + QModbusReply *reply = m_modbusTcpMaster->sendWriteRequest(unit, static_cast(m_slaveId)); + if (!reply) { + qCWarning(dcV2c()) << "TrydanModbusTcpMaster: sendWriteRequest returned null for reg" << Qt::hex << address; + callback(address, false); + return; + } + + connect(reply, &QModbusReply::finished, this, [reply, address, callback]() { + reply->deleteLater(); + callback(address, reply->error() == QModbusDevice::NoError); + }); +} + +void TrydanModbusTcpMaster::onConnectionStateChanged(bool connected) +{ + if (connected) { + m_errorCount = 0; + // Signal reachability so the plugin calls initialize(). + setReachable(true); + } else { + m_updating = false; + m_initializing = false; + setReachable(false); + } +} + +void TrydanModbusTcpMaster::handleError() +{ + m_errorCount++; + if (m_errorCount >= k_errorLimit) { + qCWarning(dcV2c()) << "TrydanModbusTcpMaster: error limit reached, marking unreachable"; + setReachable(false); + } +} diff --git a/v2c/trydanmodbustcpmaster.h b/v2c/trydanmodbustcpmaster.h new file mode 100644 index 0000000..9703799 --- /dev/null +++ b/v2c/trydanmodbustcpmaster.h @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef TRYDANMODBUSTCPMASTER_H +#define TRYDANMODBUSTCPMASTER_H + +#include "trydanmodbusmaster.h" + +#include +#include +#include +#include + +class ModbusTcpMaster; +class QModbusReply; + +/*! + * \brief Modbus TCP transport for the V2C Trydan (Étape 1). + * + * Wraps a libnymea-modbus ModbusTcpMaster. All register reads are individual + * FC3 transactions of exactly 2 registers — never batched — to avoid the + * overlapping-window firmware bug of the V2C Trydan. + * cf. modbus.py _read_register and AGENTS.md § Décodage lecture. + * + * Writes use FC6 (single-register, uint16, via ModbusTcpMaster::sendWriteRequest). + * + * Étape 2 (RTU) will add TrydanModbusRtuMaster on the same TrydanModbusMaster + * interface without touching this file. + */ +class TrydanModbusTcpMaster : public TrydanModbusMaster +{ + Q_OBJECT + +public: + /*! + * \param address IP address of the charger + * \param port Modbus TCP port (default 502) + * \param slaveId Modbus unit ID (always 1 for Trydan) + */ + explicit TrydanModbusTcpMaster(const QHostAddress &address, + quint16 port, + quint16 slaveId, + QObject *parent = nullptr); + ~TrydanModbusTcpMaster() override; + + /*! \brief Underlying TCP master — exposed so the plugin can update the host address. */ + ModbusTcpMaster *modbusTcpMaster() const; + + /*! \brief Update the target IP without reconnecting (call reconnectDevice() separately). */ + void setHostAddress(const QHostAddress &address); + + void connectDevice() override; + void disconnectDevice() override; + bool initialize() override; + bool update() override; + + void writePauseState(quint16 value) override; + void writeLock(quint16 value) override; + void writeIntensity(quint16 amps) override; + void writePauseDynamic(quint16 value) override; + +private: + // Callback type for a single 2-register read result + using ReadCallback = std::function; + using ReadStep = QPair; + + /*! + * \brief Execute \a steps sequentially, one FC3 read per register pair. + * + * The sequence aborts on the first failed read; \a onDone receives false in + * that case and the error counter is incremented. On full success \a onDone + * receives true and the error counter is reset. + * + * Sequential execution is required because concurrent reads from the same + * slave on a Modbus TCP connection would not be incorrect per protocol, but + * the firmware's overlapping register windows make ordering critical. + */ + void runReadSequence(QList steps, std::function onDone); + void doNextRead(QList steps, int index, std::function onDone); + + /*! + * \brief Send an FC6 (Write Single Register) request and invoke \a callback. + * FC6 is used for all writes per the V2C spec (modbus.py _write_register). + * \param callback Receives (address, success) so callers can emit writeCompleted + * with the originating register address, enabling action handlers + * to distinguish expected writes from background writes. + */ + void writeFC6(quint16 address, quint16 value, + std::function callback); + + void onConnectionStateChanged(bool connected); + + /*! \brief Increment the error counter and mark unreachable once it hits the limit. */ + void handleError(); + + QList buildInitSteps(); + QList buildPollSteps(); + + ModbusTcpMaster *m_modbusTcpMaster = nullptr; + quint16 m_slaveId = 1; + int m_errorCount = 0; + bool m_updating = false; + bool m_initializing = false; + + static constexpr int k_errorLimit = 5; +}; + +#endif // TRYDANMODBUSTCPMASTER_H