diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index 5c50122..943e1d1 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -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 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(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()) { diff --git a/sunspec/integrationpluginsunspec.h b/sunspec/integrationpluginsunspec.h index 72ffc36..968832c 100644 --- a/sunspec/integrationpluginsunspec.h +++ b/sunspec/integrationpluginsunspec.h @@ -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 ¶mTypeId, const QVariant &value); diff --git a/sunspec/integrationpluginsunspec.json b/sunspec/integrationpluginsunspec.json index 81bf635..3fb302d 100644 --- a/sunspec/integrationpluginsunspec.json +++ b/sunspec/integrationpluginsunspec.json @@ -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":[ diff --git a/sunspec/sunspecstoragelogic.cpp b/sunspec/sunspecstoragelogic.cpp index c2b15d9..5510a9c 100644 --- a/sunspec/sunspecstoragelogic.cpp +++ b/sunspec/sunspecstoragelogic.cpp @@ -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 &setpoints, quint16 revertTime) +{ + SetpointPlan plan; + + QVector 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(revertTime)}); + + return plan; +} + } diff --git a/sunspec/sunspecstoragelogic.h b/sunspec/sunspecstoragelogic.h index 815c79c..196b311 100644 --- a/sunspec/sunspecstoragelogic.h +++ b/sunspec/sunspecstoragelogic.h @@ -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 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 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 &setpoints, quint16 revertTime); + } #endif // SUNSPECSTORAGELOGIC_H