sunspec: arm the storage revert timer after every setpoint

setInOutWRteRvrtTms() existed but was never called. InOutWRte_RvrtTms is
the delay after which the inverter drops the setpoint and resumes its
native behaviour. Left at 0, a setpoint stays active indefinitely, HEMS
stopped, network lost or process crashed included - the battery stays
where the last command put it.

Add a per-thing revertTime parameter, 0..28800 s, defaulting to 0 so the
generic vendor keeps its current behaviour, and route every setpoint
through SunSpecStorageLogic::planSetpoint(). The plan orders the writes -
ChaGriSet, StorCtl_Mod, then the rates - and appends the revert timer
last, so the timer never starts counting against a setpoint that has not
been written yet.

Writes are issued sequentially rather than in parallel: the order is part
of the contract with the hardware.
This commit is contained in:
Patrick Schurig 2026-07-31 10:26:01 +02:00
parent 4ec7aa50e8
commit c5c3f46f57
5 changed files with 205 additions and 73 deletions

View File

@ -469,97 +469,101 @@ void IntegrationPluginSunSpec::executeAction(ThingActionInfo *info)
return;
}
/* Every setpoint goes through a plan rather than a lone write. The plan
* puts the registers in the order the hardware expects and closes the
* sequence with the revert timer, so that a setpoint left behind by a
* stopped HEMS expires instead of holding the battery forever. */
QVector<SunSpecStorageLogic::Write> setpoints;
if (action.actionTypeId() == sunspecStorageGridChargingActionTypeId) {
bool gridCharging = action.param(sunspecStorageGridChargingActionGridChargingParamTypeId).value().toBool();
QModbusReply *reply = storage->setChaGriSet(gridCharging ? SunSpecStorageModel::ChagrisetGrid : SunSpecStorageModel::ChagrisetPv);
if (!reply) {
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater);
connect(reply, &QModbusReply::finished, info, [info, reply]{
if (reply->error() != QModbusDevice::NoError) {
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
info->finish(Thing::ThingErrorNoError);
});
setpoints.append({SunSpecStorageLogic::RegisterChaGriSet,
gridCharging ? double(SunSpecStorageModel::ChagrisetGrid) : double(SunSpecStorageModel::ChagrisetPv)});
} else if (action.actionTypeId() == sunspecStorageEnableChargingActionTypeId) {
SunSpecStorageModel::Storctl_modFlags controlModeFlags = storage->storCtlMod();
bool enabled = action.param(sunspecStorageEnableChargingActionEnableChargingParamTypeId).value().toBool();
controlModeFlags.setFlag(SunSpecStorageModel::Storctl_modCharge, enabled);
QModbusReply *reply = storage->setStorCtlMod(controlModeFlags);
if (!reply) {
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater);
connect(reply, &QModbusReply::finished, info, [info, reply, enabled]{
if (reply->error() != QModbusDevice::NoError) {
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
info->thing()->setStateValue(sunspecStorageEnableChargingStateTypeId, enabled);
info->finish(Thing::ThingErrorNoError);
});
setpoints.append({SunSpecStorageLogic::RegisterStorCtlMod, double(int(controlModeFlags))});
} else if (action.actionTypeId() == sunspecStorageEnableDischargingActionTypeId) {
SunSpecStorageModel::Storctl_modFlags controlModeFlags = storage->storCtlMod();
bool enabled = action.param(sunspecStorageEnableDischargingActionEnableDischargingParamTypeId).value().toBool();
controlModeFlags.setFlag(SunSpecStorageModel::Storctl_modDiScharge, enabled);
QModbusReply *reply = storage->setStorCtlMod(controlModeFlags);
if (!reply) {
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater);
connect(reply, &QModbusReply::finished, info, [info, reply, enabled]{
if (reply->error() != QModbusDevice::NoError) {
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
info->thing()->setStateValue(sunspecStorageEnableDischargingStateTypeId, enabled);
info->finish(Thing::ThingErrorNoError);
});
setpoints.append({SunSpecStorageLogic::RegisterStorCtlMod, double(int(controlModeFlags))});
} else if (action.actionTypeId() == sunspecStorageChargingRateActionTypeId) {
QModbusReply *reply = storage->setInWRte(action.param(sunspecStorageChargingRateActionChargingRateParamTypeId).value().toInt());
if (!reply) {
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater);
connect(reply, &QModbusReply::finished, info, [info, reply]{
if (reply->error() != QModbusDevice::NoError) {
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
info->finish(Thing::ThingErrorNoError);
});
setpoints.append({SunSpecStorageLogic::RegisterInWRte,
action.param(sunspecStorageChargingRateActionChargingRateParamTypeId).value().toDouble()});
} else if (action.actionTypeId() == sunspecStorageDischargingRateActionTypeId) {
QModbusReply *reply = storage->setOutWRte(action.param(sunspecStorageDischargingRateActionDischargingRateParamTypeId).value().toInt());
if (!reply) {
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater);
connect(reply, &QModbusReply::finished, info, [info, reply]{
if (reply->error() != QModbusDevice::NoError) {
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
info->finish(Thing::ThingErrorNoError);
});
setpoints.append({SunSpecStorageLogic::RegisterOutWRte,
action.param(sunspecStorageDischargingRateActionDischargingRateParamTypeId).value().toDouble()});
} else {
Q_ASSERT_X(false, "executeAction", QString("Unhandled action: %1").arg(action.actionTypeId().toString()).toUtf8());
info->finish(Thing::ThingErrorActionTypeNotFound);
return;
}
const uint revertTime = thing->paramValue(sunspecStorageThingRevertTimeParamTypeId).toUInt();
executeSetpointPlan(info, storage, SunSpecStorageLogic::planSetpoint(setpoints, revertTime));
} else {
Q_ASSERT_X(false, "executeAction", QString("Unhandled thingClassId: %1").arg(info->thing()->thingClassId().toString()).toUtf8());
}
}
QModbusReply *IntegrationPluginSunSpec::writeSetpointRegister(SunSpecStorageModel *storage, const SunSpecStorageLogic::Write &write)
{
switch (write.reg) {
case SunSpecStorageLogic::RegisterChaGriSet:
return storage->setChaGriSet(write.value != 0 ? SunSpecStorageModel::ChagrisetGrid
: SunSpecStorageModel::ChagrisetPv);
case SunSpecStorageLogic::RegisterStorCtlMod:
return storage->setStorCtlMod(SunSpecStorageModel::Storctl_modFlags(int(write.value)));
case SunSpecStorageLogic::RegisterInWRte:
return storage->setInWRte(write.value);
case SunSpecStorageLogic::RegisterOutWRte:
return storage->setOutWRte(write.value);
case SunSpecStorageLogic::RegisterRvrtTms:
return storage->setInOutWRteRvrtTms(static_cast<quint16>(write.value));
}
return nullptr;
}
/* The writes are issued one after another rather than all at once: the order is
* part of the contract with the hardware, and several inverters ask for
* sequential requests. */
void IntegrationPluginSunSpec::executeSetpointWrite(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan, int index)
{
if (index >= plan.writes.count()) {
info->finish(Thing::ThingErrorNoError);
return;
}
const SunSpecStorageLogic::Write write = plan.writes.at(index);
QModbusReply *reply = writeSetpointRegister(storage, write);
if (!reply) {
qCWarning(dcSunSpec()) << "Could not write" << SunSpecStorageLogic::registerName(write.reg) << "on" << info->thing();
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater);
connect(reply, &QModbusReply::finished, info, [this, info, storage, plan, index, write, reply] {
if (reply->error() != QModbusDevice::NoError) {
qCWarning(dcSunSpec()) << "Error writing" << SunSpecStorageLogic::registerName(write.reg)
<< "on" << info->thing() << reply->errorString();
info->finish(Thing::ThingErrorHardwareFailure);
return;
}
executeSetpointWrite(info, storage, plan, index + 1);
});
}
void IntegrationPluginSunSpec::executeSetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan)
{
executeSetpointWrite(info, storage, plan, 0);
}
Thing *IntegrationPluginSunSpec::getThingForSunSpecModel(uint modelId, uint modbusAddress, const ThingId &parentId)
{
foreach (Thing *thing, myThings()) {

View File

@ -109,6 +109,11 @@ private:
bool hasManufacturer(const QStringList &manufacturers, const QString &manufacturer);
void markThingStatesDisconnected(Thing *thing);
// Storage setpoints
void executeSetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan);
void executeSetpointWrite(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan, int index);
QModbusReply *writeSetpointRegister(SunSpecStorageModel *storage, const SunSpecStorageLogic::Write &write);
private slots:
void onRefreshTimer();
void onPluginConfigurationChanged(const ParamTypeId &paramTypeId, const QVariant &value);

View File

@ -1349,6 +1349,16 @@
"displayName": "Serial number",
"type": "QString",
"defaultValue": "Unkown"
},
{
"id": "74d530d1-6fd3-4afa-8acd-1e91041f88b2",
"name":"revertTime",
"displayName": "Setpoint revert time",
"type": "uint",
"unit": "Seconds",
"minValue": 0,
"maxValue": 28800,
"defaultValue": 0
}
],
"stateTypes":[

View File

@ -56,4 +56,71 @@ StorageStates readStates(SunSpecStorageModel *storage)
return states;
}
bool isScaledRate(Register reg)
{
return reg == RegisterInWRte || reg == RegisterOutWRte;
}
QString registerName(Register reg)
{
switch (reg) {
case RegisterChaGriSet: return QStringLiteral("ChaGriSet");
case RegisterStorCtlMod: return QStringLiteral("StorCtl_Mod");
case RegisterInWRte: return QStringLiteral("InWRte");
case RegisterOutWRte: return QStringLiteral("OutWRte");
case RegisterRvrtTms: return QStringLiteral("InOutWRte_RvrtTms");
}
return QString();
}
/* Rank within the write order: ChaGriSet, then StorCtl_Mod, then the rates,
* then the revert timer. Arming the timer first would start it counting against
* a setpoint that has not been written yet. */
static int writeOrderRank(Register reg)
{
switch (reg) {
case RegisterChaGriSet: return 0;
case RegisterStorCtlMod: return 1;
case RegisterInWRte:
case RegisterOutWRte: return 2;
case RegisterRvrtTms: return 3;
}
return 3;
}
SetpointPlan planSetpoint(const QVector<Write> &setpoints, quint16 revertTime)
{
SetpointPlan plan;
QVector<Write> writes;
foreach (const Write &write, setpoints) {
/* The revert time is appended below, once, in last position. A caller
* passing it explicitly must not end up with two of them. */
if (write.reg == RegisterRvrtTms)
continue;
if (isScaledRate(write.reg))
plan.rereadScaleFactor = true;
writes.append(write);
}
std::stable_sort(writes.begin(), writes.end(), [](const Write &left, const Write &right) {
return writeOrderRank(left.reg) < writeOrderRank(right.reg);
});
/* Every register that carries the intent is read back afterwards. The
* revert timer is excluded: it is a countdown, so its read-back value is
* not expected to match what was written. */
foreach (const Write &write, writes) {
if (!plan.verifyReadback.contains(write.reg))
plan.verifyReadback.append(write.reg);
}
plan.writes = writes;
plan.writes.append({RegisterRvrtTms, static_cast<double>(revertTime)});
return plan;
}
}

View File

@ -31,12 +31,47 @@
/* Decision logic of the storage (model 124) handling, kept free of the
* integration framework so it can be exercised without a running nymea.
*
* readStates() maps a storage block update onto the state values the plugin
* publishes. Having it in one place is what makes it possible to check that a
* state is fed from the very register the matching action writes to.
* Two things live here:
*
* - readStates() maps a storage block update onto the state values the plugin
* publishes. Having it in one place is what makes it possible to check that
* a state is fed from the very register the matching action writes to;
* - planSetpoint() turns a set of setpoints into an ordered sequence of
* register writes. Order matters on real hardware, and the revert timer
* must close every sequence.
*/
namespace SunSpecStorageLogic {
/* The registers of model 124 this plugin ever writes. */
enum Register {
RegisterChaGriSet,
RegisterStorCtlMod,
RegisterInWRte,
RegisterOutWRte,
RegisterRvrtTms
};
struct Write {
Register reg;
double value;
};
struct SetpointPlan {
/* Scale factors are not necessarily static: they may change with firmware
* and at runtime. Whenever a scaled quantity is written, its scale factor
* should be read again immediately before the write rather than reused
* from an earlier poll. */
bool rereadScaleFactor = false;
/* Ordered: ChaGriSet, StorCtl_Mod, InWRte/OutWRte, RvrtTms last. */
QVector<Write> writes;
/* Registers to read back and compare after the sequence. Several inverters
* accept a write and then ignore it without raising a Modbus exception, so
* an unverified setpoint is an unapplied setpoint. */
QVector<Register> verifyReadback;
};
struct StorageStates {
/* Percent of WChaMax, from InWRte / OutWRte - the very registers the
* chargingRate / dischargingRate actions write to. */
@ -63,8 +98,19 @@ struct StorageStates {
const double MinimumRatePercent = -100.0;
const double MaximumRatePercent = 100.0;
/* Range of InOutWRte_RvrtTms. */
const int MaximumRevertTimeSeconds = 28800;
StorageStates readStates(SunSpecStorageModel *storage);
/* True for the registers carried by InOutWRte_SF, i.e. those whose write should
* be preceded by a fresh read of that scale factor. */
bool isScaledRate(Register reg);
QString registerName(Register reg);
SetpointPlan planSetpoint(const QVector<Write> &setpoints, quint16 revertTime);
}
#endif // SUNSPECSTORAGELOGIC_H