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>
This commit is contained in:
parent
8f017dbb91
commit
14f07cc638
32
v2c/trydanmodbusmaster.cpp
Normal file
32
v2c/trydanmodbusmaster.cpp
Normal file
@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "trydanmodbusmaster.h"
|
||||
#include <cstring>
|
||||
|
||||
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<quint32>(high) << 16) | static_cast<quint32>(low);
|
||||
float result;
|
||||
std::memcpy(&result, &raw, sizeof(float));
|
||||
return result;
|
||||
}
|
||||
|
||||
int TrydanModbusMaster::decodeIntFromFloat32(quint16 high, quint16 low)
|
||||
{
|
||||
return static_cast<int>(qRound(decodeFloat32BB(high, low)));
|
||||
}
|
||||
|
||||
void TrydanModbusMaster::setReachable(bool reachable)
|
||||
{
|
||||
if (m_reachable == reachable)
|
||||
return;
|
||||
m_reachable = reachable;
|
||||
emit reachableChanged(reachable);
|
||||
}
|
||||
211
v2c/trydanmodbusmaster.h
Normal file
211
v2c/trydanmodbusmaster.h
Normal file
@ -0,0 +1,211 @@
|
||||
// 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-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
|
||||
262
v2c/trydanmodbustcpmaster.cpp
Normal file
262
v2c/trydanmodbustcpmaster.cpp
Normal file
@ -0,0 +1,262 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "trydanmodbustcpmaster.h"
|
||||
|
||||
#include <modbustcpmaster.h>
|
||||
|
||||
#include <QtSerialBus>
|
||||
|
||||
#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::ReadStep> 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::ReadStep> 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<ReadStep> steps,
|
||||
std::function<void(bool)> onDone)
|
||||
{
|
||||
doNextRead(std::move(steps), 0, std::move(onDone));
|
||||
}
|
||||
|
||||
void TrydanModbusTcpMaster::doNextRead(QList<ReadStep> steps,
|
||||
int index,
|
||||
std::function<void(bool)> 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<int>(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<void(quint16, bool)> 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<int>(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);
|
||||
}
|
||||
}
|
||||
107
v2c/trydanmodbustcpmaster.h
Normal file
107
v2c/trydanmodbustcpmaster.h
Normal file
@ -0,0 +1,107 @@
|
||||
// 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
|
||||
Loading…
x
Reference in New Issue
Block a user