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>
263 lines
8.7 KiB
C++
263 lines
8.7 KiB
C++
// 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);
|
|
}
|
|
}
|