etm-powersync-plugins-modbus/v2c/trydanmodbustcpmaster.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

108 lines
4.0 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef TRYDANMODBUSTCPMASTER_H
#define TRYDANMODBUSTCPMASTER_H
#include "trydanmodbusmaster.h"
#include <QHostAddress>
#include <QList>
#include <QPair>
#include <functional>
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<void(bool ok, quint16 high, quint16 low)>;
using ReadStep = QPair<quint16, ReadCallback>;
/*!
* \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<ReadStep> steps, std::function<void(bool)> onDone);
void doNextRead(QList<ReadStep> steps, int index, std::function<void(bool)> 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<void(quint16 addr, bool ok)> callback);
void onConnectionStateChanged(bool connected);
/*! \brief Increment the error counter and mark unreachable once it hits the limit. */
void handleError();
QList<ReadStep> buildInitSteps();
QList<ReadStep> 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