// 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) { // qBound to IEC 61851 absolute limits [6, 32]. The real installation // ceiling comes from MaxIntensity read above (a 16 A mono Trydan reports // 16, not 32). The lower guard (qMax 6 was wrong for aberrant 0 returns: // qMin(32, 0)=0 → maxIntensity()==0 → every setpoint gets clamped to 0 // and then the <6 A path pauses charging silently). qBound floors to 6 // on a spurious 0 and caps to 32 on a spurious 65535. m_minIntensity = qBound(6, m_minIntensity, 32); m_maxIntensity = qBound(6, m_maxIntensity, 32); } 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); } }