feat: Installer Setup — LoadAdapterRegistry + adapter layer (v12)

Adds a thin adapter layer that maps installer-visible roles (EVCharger,
DHW, HeatPump, Battery, SolarMeter, GridMeter) to nymea Things and
applies Scheduler slot decisions to real hardware.

New files:
- energyplugin/types/loadrole.h — LoadRole enum + compat interface map
- energyplugin/adapters/iloadapter.h — abstract ILoadAdapter interface
- energyplugin/adapters/relayadapter.h/.cpp — binary on/off relay (DHW/HP)
- energyplugin/adapters/sgreadyadapter.h/.cpp — SG-Ready 2-relay HP (SG1–SG4)
- energyplugin/adapters/evchargeradapter.h/.cpp — EV charger W→A adapter
- energyplugin/adapters/batteryadapter.h/.cpp — battery charge/discharge/standby
- energyplugin/adapters/loadadapterregistry.h/.cpp — role→Thing→adapter registry
- energyplugin/adapters/adaptersettings.h/.cpp — persists to adapters.conf (INI)

Modified:
- SchedulerManager: optional LoadAdapterRegistry param; applyCurrentSlot()
  now calls adapter->applyPower() for DHW/HP/Battery roles
- NymeaEnergyJsonHandler: 6 new v12 methods (GetSetupStatus,
  GetCompatibleThings, AssignThingToRole, UnassignRole, SetRoleEnabled,
  TestRoleConnection) + 3 push notifications
- energypluginnymea.cpp: wires AdapterSettings + LoadAdapterRegistry,
  bumps JSON-RPC version to 12
- energyplugin.pri: adds 7 headers + 6 sources
- testscheduler: 4 new tests (detectAdapterType, AdapterSettings round-trip,
  null-registry no-crash, LoadRole serialization) — 15/15 pass
- doc.md: section 12 Installer Setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-02-24 09:15:06 +01:00
parent 69246618ee
commit 67836b7234
23 changed files with 2524 additions and 16 deletions

170
doc.md
View File

@ -1524,3 +1524,173 @@ NymeaSettings::settingsPath() + "/scheduler.conf" [section: manualSlots]
---
*Section 11 ajoutée le 2026-02-24 — ManualStrategy Community Tier*
---
## Section 12 — Installer Setup (LoadAdapterRegistry, v12)
### 12.1 Vue d'ensemble
L'**Installer Setup** permet à un installateur de mapper des rôles énergétiques
(`EVCharger`, `DHW`, `HeatPump`, `Battery`, `SolarMeter`, `GridMeter`) à des Things nymea concrètes.
Le `LoadAdapterRegistry` crée ensuite l'adaptateur approprié (relay, SG-Ready, evcharger, battery)
et transmet les consignes du Scheduler au matériel réel.
**Flux en 3 étapes :**
1. Activer le rôle → `SetRoleEnabled(role, true)`
2. Choisir la Thing → `AssignThingToRole(role, thingId)`
3. Tester la connexion → `TestRoleConnection(role)` → notification `ConnectionTestResult`
### 12.2 Tableau de compatibilité (rôle → interfaces requises)
| Rôle | Interfaces nymea requises |
|---|---|
| `EVCharger` | `evcharger` |
| `DHW` | `relay` ou `smartmeterconsumer` |
| `HeatPump` | `heating` (SG-Ready) ou `relay` |
| `Battery` | `energystorage` |
| `SolarMeter` | `energymeter` |
| `GridMeter` | `energymeter` |
### 12.3 Détection automatique du type d'adaptateur
| Rôle | Interface détectée | Adaptateur créé |
|---|---|---|
| `EVCharger` | `evcharger` | `EvChargerAdapter` |
| `DHW` | `relay` | `RelayAdapter` |
| `HeatPump` | `heating` | `SgReadyAdapter` (2 relais) |
| `HeatPump` | `relay` | `RelayAdapter` |
| `Battery` | `energystorage` | `BatteryAdapter` |
| `SolarMeter` | `energymeter` | read-only (pas d'adaptateur) |
| `GridMeter` | `energymeter` | read-only (pas d'adaptateur) |
### 12.4 Référence API JSON-RPC v12
#### `GetSetupStatus``{ setupStatus: object }`
Retourne l'état de tous les rôles.
```json
{
"method": "NymeaEnergy.GetSetupStatus",
"params": {}
}
// Réponse:
{
"setupStatus": {
"roles": [
{
"role": "DHW",
"enabled": true,
"assigned": true,
"assignedThingId": "…uuid…",
"assignedThingName": "Ballon ECS",
"reachable": true,
"adapterType": "relay",
"lastError": ""
}
],
"allEnabledRolesOk": true,
"configuredCount": 2,
"errorCount": 0
}
}
```
#### `GetCompatibleThings``{ role, things: [] }`
```json
{ "method": "NymeaEnergy.GetCompatibleThings", "params": { "role": "DHW" } }
```
#### `AssignThingToRole``{ energyError, adapterType, detectedParams }`
```json
{
"method": "NymeaEnergy.AssignThingToRole",
"params": {
"role": "HeatPump",
"thingId": "…relay1-uuid…",
"params": { "relay1ThingId": "…", "relay2ThingId": "…", "normalPowerW": 1500 }
}
}
```
#### `UnassignRole``{ energyError }`
```json
{ "method": "NymeaEnergy.UnassignRole", "params": { "role": "Battery" } }
```
#### `SetRoleEnabled``{ energyError }`
```json
{ "method": "NymeaEnergy.SetRoleEnabled", "params": { "role": "DHW", "enabled": true } }
```
#### `TestRoleConnection``{ energyError }` + notification `ConnectionTestResult`
```json
{ "method": "NymeaEnergy.TestRoleConnection", "params": { "role": "DHW" } }
// Notification:
{ "notification": "NymeaEnergy.ConnectionTestResult",
"params": { "role": "DHW", "success": true, "message": "Connection test OK" } }
```
### 12.5 Notifications push
| Notification | Payload | Déclencheur |
|---|---|---|
| `SetupStatusChanged` | `{ setupStatus }` | Assignment, enable, reachability |
| `ConnectionTestResult` | `{ role, success, message }` | Fin de `TestRoleConnection` |
| `ThingBecameCompatible` | `{ role, thing }` | Nouvelle Thing ajoutée dans nymea |
### 12.6 Gestion des erreurs — Thing disparue
Si une Thing assignée disparaît au redémarrage du daemon :
- L'entrée dans `adapters.conf` est conservée
- `RoleStatus.reachable = false`, `lastError = "Thing not found: <uuid>"`
- `SetupStatus.allEnabledRolesOk = false`
- Notification `SetupStatusChanged` émise avec l'erreur
- Dès que la Thing réapparaît (`ThingManager::thingAdded`), notification `ThingBecameCompatible`
### 12.7 Exemple complet — EV charger hebdomadaire via Installer Setup
```json
// 1. Activer le rôle
{ "method": "NymeaEnergy.SetRoleEnabled",
"params": { "role": "EVCharger", "enabled": true } }
// 2. Lister les Things compatibles
{ "method": "NymeaEnergy.GetCompatibleThings",
"params": { "role": "EVCharger" } }
// 3. Assigner la Thing choisie
{ "method": "NymeaEnergy.AssignThingToRole",
"params": { "role": "EVCharger", "thingId": "…uuid…", "params": { "phases": 1 } } }
// 4. Tester
{ "method": "NymeaEnergy.TestRoleConnection",
"params": { "role": "EVCharger" } }
// → ConnectionTestResult { "success": true, "message": "EV charger connected" }
// 5. Configurer un créneau manuel EV
{ "method": "NymeaEnergy.SetManualSlot",
"params": {
"start": "2026-02-23T22:00:00.000Z",
"end": "2026-02-24T06:00:00.000Z",
"label": "Recharge hebdo VE",
"repeating": true,
"allocations": { "ev": 2000 }
}
}
```
### 12.8 Persistance
Les assignements sont sauvegardés dans :
```
NymeaSettings::settingsPath() + "/adapters.conf"
```
Format INI, identique à `scheduler.conf`. Chargés automatiquement au démarrage du daemon.
---
*Section 12 ajoutée le 2026-02-24 — Installer Setup LoadAdapterRegistry v12*

View File

@ -0,0 +1,126 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "adaptersettings.h"
#include <nymeasettings.h>
#include <QSettings>
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
AdapterSettings::AdapterSettings(QObject *parent)
: QObject(parent)
{
load();
}
QString AdapterSettings::settingsFilePath() const
{
// Allow test override via environment variable
const QString envPath = qEnvironmentVariable("NYMEA_ADAPTER_SETTINGS");
if (!envPath.isEmpty())
return envPath;
return NymeaSettings::settingsPath() + QStringLiteral("/adapters.conf");
}
QList<QVariantMap> AdapterSettings::assignments() const
{
return m_assignments;
}
void AdapterSettings::save(const QList<QVariantMap> &assignments)
{
m_assignments = assignments;
QSettings s(settingsFilePath(), QSettings::IniFormat);
s.remove(QStringLiteral("roles")); // clear previous
s.beginWriteArray(QStringLiteral("roles"), m_assignments.size());
for (int i = 0; i < m_assignments.size(); ++i) {
s.setArrayIndex(i);
const QVariantMap &m = m_assignments.at(i);
for (auto it = m.constBegin(); it != m.constEnd(); ++it) {
s.setValue(it.key(), it.value());
}
}
s.endArray();
qCDebug(dcNymeaEnergy()) << "AdapterSettings: saved" << m_assignments.size()
<< "role assignment(s) to" << settingsFilePath();
}
void AdapterSettings::setAssignment(LoadRole role, const QVariantMap &params)
{
const QString roleStr = loadRoleToString(role);
for (int i = 0; i < m_assignments.size(); ++i) {
if (m_assignments.at(i).value(QStringLiteral("role")).toString() == roleStr) {
QVariantMap merged = m_assignments.at(i);
for (auto it = params.constBegin(); it != params.constEnd(); ++it)
merged.insert(it.key(), it.value());
merged.insert(QStringLiteral("role"), roleStr);
m_assignments[i] = merged;
save(m_assignments);
return;
}
}
// New assignment
QVariantMap m = params;
m.insert(QStringLiteral("role"), roleStr);
m_assignments.append(m);
save(m_assignments);
}
void AdapterSettings::removeAssignment(LoadRole role)
{
const QString roleStr = loadRoleToString(role);
for (int i = 0; i < m_assignments.size(); ++i) {
if (m_assignments.at(i).value(QStringLiteral("role")).toString() == roleStr) {
m_assignments.removeAt(i);
save(m_assignments);
return;
}
}
}
void AdapterSettings::load()
{
m_assignments.clear();
QSettings s(settingsFilePath(), QSettings::IniFormat);
int count = s.beginReadArray(QStringLiteral("roles"));
for (int i = 0; i < count; ++i) {
s.setArrayIndex(i);
QVariantMap m;
foreach (const QString &key, s.childKeys())
m.insert(key, s.value(key));
if (!m.isEmpty())
m_assignments.append(m);
}
s.endArray();
qCDebug(dcNymeaEnergy()) << "AdapterSettings: loaded" << m_assignments.size()
<< "role assignment(s) from" << settingsFilePath();
}

View File

@ -0,0 +1,84 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef ADAPTERSETTINGS_H
#define ADAPTERSETTINGS_H
#include <QObject>
#include <QList>
#include <QVariantMap>
#include "types/loadrole.h"
// Persists LoadAdapterRegistry role assignments to:
// NymeaSettings::settingsPath() + "/adapters.conf" (QSettings INI)
//
// Section layout:
// [roles]
// size = N
//
// [roles/1]
// role = DHW
// enabled = true
// thingId = <uuid>
// adapterType = relay
// nominalPowerW = 2000
//
// [roles/2]
// role = HeatPump
// enabled = true
// thingId = <uuid>
// adapterType = sgready
// relay1ThingId = <uuid>
// relay2ThingId = <uuid>
// normalPowerW = 1500
class AdapterSettings : public QObject
{
Q_OBJECT
public:
explicit AdapterSettings(QObject *parent = nullptr);
// Return all saved role assignments (one QVariantMap per role).
// Each map contains at least: "role", "enabled", "thingId", "adapterType".
QList<QVariantMap> assignments() const;
// Persist the given set of raw assignment maps (from LoadAdapterRegistry::rawAssignments()).
void save(const QList<QVariantMap> &assignments);
// Override a single role's assignment (loads first, then upserts).
void setAssignment(LoadRole role, const QVariantMap &params);
// Remove a single role's assignment by role name.
void removeAssignment(LoadRole role);
// Path of the settings file (for testing: override via NYMEA_ADAPTER_SETTINGS env var).
QString settingsFilePath() const;
private:
void load();
QList<QVariantMap> m_assignments;
};
#endif // ADAPTERSETTINGS_H

View File

@ -0,0 +1,160 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "batteryadapter.h"
#include "types/action.h"
#include "integrations/thingmanager.h"
#include "integrations/thingactioninfo.h"
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
BatteryAdapter::BatteryAdapter(ThingManager *thingManager,
const ThingId &thingId,
double capacityKwh,
double maxChargeW,
double maxDischargeW,
QObject *parent)
: ILoadAdapter(parent),
m_thingManager(thingManager),
m_capacityKwh(capacityKwh),
m_maxChargeW(maxChargeW),
m_maxDischargeW(maxDischargeW)
{
m_thing = thingManager->findConfiguredThing(thingId);
if (!m_thing)
qCWarning(dcNymeaEnergy()) << "BatteryAdapter: thing not found:" << thingId;
}
QString BatteryAdapter::adapterId() const
{
return QStringLiteral("battery");
}
LoadRole BatteryAdapter::role() const
{
return LoadRole::Battery;
}
ThingId BatteryAdapter::thingId() const
{
return m_thing ? m_thing->id() : ThingId();
}
void BatteryAdapter::applyPower(double targetPowerW)
{
if (!m_thing) {
qCWarning(dcNymeaEnergy()) << "BatteryAdapter::applyPower: no thing";
emit powerApplied(0, false);
return;
}
QString actionName;
double powerArg = 0;
if (targetPowerW > 0) {
actionName = QStringLiteral("setChargingPower");
powerArg = qMin(targetPowerW, m_maxChargeW);
} else if (targetPowerW < 0) {
actionName = QStringLiteral("setDischargingPower");
powerArg = qMin(-targetPowerW, m_maxDischargeW);
} else {
actionName = QStringLiteral("setStandby");
}
ActionType actionType;
foreach (const ActionType &at, m_thing->thingClass().actionTypes()) {
if (at.name() == actionName) { actionType = at; break; }
}
if (actionType.id().isNull()) {
qCWarning(dcNymeaEnergy()) << "BatteryAdapter: action" << actionName
<< "not found on" << m_thing->name();
emit powerApplied(0, false);
return;
}
Action action(actionType.id(), m_thing->id(), Action::TriggeredByRule);
// For setStandby there are no params; for charge/discharge add the power param
if (targetPowerW != 0) {
ParamTypeId powerParamId;
foreach (const ParamType &pt, actionType.paramTypes()) {
if (pt.name() == QLatin1String("power")) { powerParamId = pt.id(); break; }
}
if (!powerParamId.isNull())
action.setParams(ParamList() << Param(powerParamId, powerArg));
}
ThingActionInfo *info = m_thingManager->executeAction(action);
connect(info, &ThingActionInfo::finished, this, [this, targetPowerW, info]() {
bool ok = (info->status() == Thing::ThingErrorNoError);
emit powerApplied(targetPowerW, ok);
if (!ok)
qCWarning(dcNymeaEnergy()) << "BatteryAdapter: action failed on" << m_thing->name();
});
}
void BatteryAdapter::testConnection()
{
if (!m_thing) {
emit testResult(false, QStringLiteral("Thing not found"));
return;
}
// Try reading batteryLevel or currentPower state
foreach (const StateType &st, m_thing->thingClass().stateTypes()) {
if (st.name() == QLatin1String("batteryLevel") ||
st.name() == QLatin1String("currentPower")) {
emit testResult(true, QStringLiteral("Battery state readable: %1 = %2")
.arg(st.name())
.arg(m_thing->stateValue(st.id()).toDouble()));
return;
}
}
emit testResult(true, QStringLiteral("Battery reachable"));
}
double BatteryAdapter::currentPowerW() const
{
if (!m_thing)
return 0;
foreach (const StateType &st, m_thing->thingClass().stateTypes()) {
if (st.name() == QLatin1String("currentPower"))
return m_thing->stateValue(st.id()).toDouble();
}
return 0;
}
bool BatteryAdapter::isReachable() const
{
if (!m_thing)
return false;
foreach (const StateType &st, m_thing->thingClass().stateTypes()) {
if (st.name() == QLatin1String("connected"))
return m_thing->stateValue(st.id()).toBool();
}
return true;
}

View File

@ -0,0 +1,65 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef BATTERYADAPTER_H
#define BATTERYADAPTER_H
#include "adapters/iloadapter.h"
#include <integrations/thingmanager.h>
// Battery adapter for bidirectional energy storage.
//
// Power convention:
// applyPower(W > 0) → charge at W watts
// applyPower(W < 0) → discharge at |W| watts
// applyPower(0) → standby
class BatteryAdapter : public ILoadAdapter
{
Q_OBJECT
public:
explicit BatteryAdapter(ThingManager *thingManager,
const ThingId &thingId,
double capacityKwh = 10.0,
double maxChargeW = 5000.0,
double maxDischargeW = 5000.0,
QObject *parent = nullptr);
QString adapterId() const override;
LoadRole role() const override;
ThingId thingId() const override;
void applyPower(double targetPowerW) override;
void testConnection() override;
double currentPowerW() const override;
bool isReachable() const override;
private:
ThingManager *m_thingManager = nullptr;
Thing *m_thing = nullptr;
double m_capacityKwh;
double m_maxChargeW;
double m_maxDischargeW;
};
#endif // BATTERYADAPTER_H

View File

@ -0,0 +1,168 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "evchargeradapter.h"
#include "types/action.h"
#include "integrations/thingmanager.h"
#include "integrations/thingactioninfo.h"
#include <QLoggingCategory>
#include <QtMath>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
EvChargerAdapter::EvChargerAdapter(ThingManager *thingManager,
const ThingId &thingId,
int phases,
int minA,
int maxA,
QObject *parent)
: ILoadAdapter(parent),
m_thingManager(thingManager),
m_phases(phases),
m_minA(minA),
m_maxA(maxA)
{
m_thing = thingManager->findConfiguredThing(thingId);
if (!m_thing)
qCWarning(dcNymeaEnergy()) << "EvChargerAdapter: thing not found:" << thingId;
}
QString EvChargerAdapter::adapterId() const
{
return QStringLiteral("evcharger");
}
LoadRole EvChargerAdapter::role() const
{
return LoadRole::EVCharger;
}
ThingId EvChargerAdapter::thingId() const
{
return m_thing ? m_thing->id() : ThingId();
}
void EvChargerAdapter::applyPower(double targetPowerW)
{
if (!m_thing) {
qCWarning(dcNymeaEnergy()) << "EvChargerAdapter::applyPower: no thing";
emit powerApplied(0, false);
return;
}
if (targetPowerW <= 0) {
// Disable charging
ActionType powerActionType;
foreach (const ActionType &at, m_thing->thingClass().actionTypes()) {
if (at.name() == QLatin1String("power")) { powerActionType = at; break; }
}
if (powerActionType.id().isNull()) {
qCWarning(dcNymeaEnergy()) << "EvChargerAdapter: no 'power' action on" << m_thing->name();
emit powerApplied(0, false);
return;
}
ParamTypeId powerParamId;
foreach (const ParamType &pt, powerActionType.paramTypes()) {
if (pt.name() == QLatin1String("power")) { powerParamId = pt.id(); break; }
}
Action action(powerActionType.id(), m_thing->id(), Action::TriggeredByRule);
action.setParams(ParamList() << Param(powerParamId, false));
ThingActionInfo *info = m_thingManager->executeAction(action);
connect(info, &ThingActionInfo::finished, this, [this, info]() {
emit powerApplied(0, info->status() == Thing::ThingErrorNoError);
});
return;
}
// Convert W → A, clamped to [minA, maxA]
double amps = targetPowerW / (m_phases * 230.0);
int clampedA = static_cast<int>(qBound(static_cast<double>(m_minA), amps, static_cast<double>(m_maxA)));
// Find "maxChargingCurrent" action type (evcharger interface)
ActionType currentActionType;
foreach (const ActionType &at, m_thing->thingClass().actionTypes()) {
if (at.name() == QLatin1String("maxChargingCurrent")) { currentActionType = at; break; }
}
if (currentActionType.id().isNull()) {
qCWarning(dcNymeaEnergy()) << "EvChargerAdapter: no 'maxChargingCurrent' action on" << m_thing->name();
emit powerApplied(0, false);
return;
}
ParamTypeId currentParamId;
foreach (const ParamType &pt, currentActionType.paramTypes()) {
if (pt.name() == QLatin1String("maxChargingCurrent")) { currentParamId = pt.id(); break; }
}
Action action(currentActionType.id(), m_thing->id(), Action::TriggeredByRule);
action.setParams(ParamList() << Param(currentParamId, clampedA));
ThingActionInfo *info = m_thingManager->executeAction(action);
double appliedW = clampedA * m_phases * 230.0;
connect(info, &ThingActionInfo::finished, this, [this, appliedW, info]() {
emit powerApplied(appliedW, info->status() == Thing::ThingErrorNoError);
});
}
void EvChargerAdapter::testConnection()
{
if (!m_thing) {
emit testResult(false, QStringLiteral("Thing not found"));
return;
}
// Check "connected" state
foreach (const StateType &st, m_thing->thingClass().stateTypes()) {
if (st.name() == QLatin1String("connected")) {
bool connected = m_thing->stateValue(st.id()).toBool();
emit testResult(connected,
connected ? QStringLiteral("EV charger connected")
: QStringLiteral("EV charger not connected"));
return;
}
}
// No "connected" state — assume reachable
emit testResult(true, QStringLiteral("EV charger reachable"));
}
double EvChargerAdapter::currentPowerW() const
{
if (!m_thing)
return 0;
foreach (const StateType &st, m_thing->thingClass().stateTypes()) {
if (st.name() == QLatin1String("currentPower"))
return m_thing->stateValue(st.id()).toDouble();
}
return 0;
}
bool EvChargerAdapter::isReachable() const
{
if (!m_thing)
return false;
foreach (const StateType &st, m_thing->thingClass().stateTypes()) {
if (st.name() == QLatin1String("connected"))
return m_thing->stateValue(st.id()).toBool();
}
return true;
}

View File

@ -0,0 +1,66 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef EVCHARGERADAPTER_H
#define EVCHARGERADAPTER_H
#include "adapters/iloadapter.h"
#include <integrations/thingmanager.h>
// EV charger adapter — translates a power setpoint into a charging current.
//
// Converts W → A using: amps = targetPowerW / (phases * 230)
// Clamps to [minA, maxA] as supported by the charger.
//
// Note: Coexists with SmartChargingManager which uses its own EvCharger wrappers.
// EVChargerAdapter is only exercised when the Scheduler explicitly allocates EV power.
class EvChargerAdapter : public ILoadAdapter
{
Q_OBJECT
public:
explicit EvChargerAdapter(ThingManager *thingManager,
const ThingId &thingId,
int phases = 1,
int minA = 6,
int maxA = 32,
QObject *parent = nullptr);
QString adapterId() const override;
LoadRole role() const override;
ThingId thingId() const override;
void applyPower(double targetPowerW) override;
void testConnection() override;
double currentPowerW() const override;
bool isReachable() const override;
private:
ThingManager *m_thingManager = nullptr;
Thing *m_thing = nullptr;
int m_phases;
int m_minA;
int m_maxA;
};
#endif // EVCHARGERADAPTER_H

View File

@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef ILOADAPTER_H
#define ILOADAPTER_H
#include <QObject>
#include <QString>
#include <integrations/thing.h>
#include "types/loadrole.h"
// Abstract interface for all load adapters.
//
// Each adapter wraps a nymea Thing and knows how to:
// - Apply a target power setpoint (applyPower)
// - Test the physical connection (testConnection)
// - Report current power draw and reachability
//
// Adapters are created by LoadAdapterRegistry based on the detected adapter type
// for a given role + Thing combination.
class ILoadAdapter : public QObject
{
Q_OBJECT
public:
explicit ILoadAdapter(QObject *parent = nullptr) : QObject(parent) {}
virtual ~ILoadAdapter() = default;
// Unique adapter type identifier (e.g. "relay", "sgready", "evcharger", "battery")
virtual QString adapterId() const = 0;
// Role this adapter serves
virtual LoadRole role() const = 0;
// Underlying nymea ThingId
virtual ThingId thingId() const = 0;
// Apply a target power setpoint in watts.
// > 0 → charge/heat at this power
// = 0 → stop / standby
// < 0 → discharge (batteries only)
virtual void applyPower(double targetPowerW) = 0;
// Initiate a connection test. Result is delivered via testResult() signal.
virtual void testConnection() = 0;
// Current reported power draw in watts (from Thing state, may be 0 if not metered).
virtual double currentPowerW() const = 0;
// Whether the underlying Thing is currently reachable.
virtual bool isReachable() const = 0;
signals:
// Emitted after testConnection() completes.
void testResult(bool success, const QString &message);
// Emitted after applyPower() completes.
void powerApplied(double powerW, bool success);
// Emitted when the reachability of the underlying Thing changes.
void reachabilityChanged(bool reachable);
};
#endif // ILOADAPTER_H

View File

@ -0,0 +1,408 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "loadadapterregistry.h"
#include "adaptersettings.h"
#include "adapters/relayadapter.h"
#include "adapters/sgreadyadapter.h"
#include "adapters/evchargeradapter.h"
#include "adapters/batteryadapter.h"
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
LoadAdapterRegistry::LoadAdapterRegistry(ThingManager *thingManager, QObject *parent)
: QObject(parent),
m_thingManager(thingManager)
{
// Initialise empty entries for every role
for (LoadRole role : allLoadRoles())
m_entries.insert(role, RoleEntry());
if (!thingManager)
return;
connect(thingManager, &ThingManager::thingAdded, this, &LoadAdapterRegistry::onThingAdded);
}
// ---------------------------------------------------------------------------
// Static helpers
// ---------------------------------------------------------------------------
QString LoadAdapterRegistry::detectAdapterType(LoadRole role, const QStringList &interfaces)
{
switch (role) {
case LoadRole::EVCharger:
if (interfaces.contains(QLatin1String("evcharger")))
return QStringLiteral("evcharger");
break;
case LoadRole::Battery:
if (interfaces.contains(QLatin1String("energystorage")))
return QStringLiteral("battery");
break;
case LoadRole::DHW:
if (interfaces.contains(QLatin1String("relay")) ||
interfaces.contains(QLatin1String("smartmeterconsumer")))
return QStringLiteral("relay");
break;
case LoadRole::HeatPump:
if (interfaces.contains(QLatin1String("heating")))
return QStringLiteral("sgready");
if (interfaces.contains(QLatin1String("relay")))
return QStringLiteral("relay");
break;
case LoadRole::SolarMeter:
case LoadRole::GridMeter:
if (interfaces.contains(QLatin1String("energymeter")))
return QStringLiteral("readonly");
break;
}
return QString();
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
void LoadAdapterRegistry::loadFromSettings(AdapterSettings *settings)
{
if (!settings || !m_thingManager)
return;
for (const QVariantMap &assignment : settings->assignments()) {
const QString roleStr = assignment.value(QStringLiteral("role")).toString();
const ThingId thingId = ThingId(assignment.value(QStringLiteral("thingId")).toString());
const bool enabled = assignment.value(QStringLiteral("enabled"), true).toBool();
QVariantMap params = assignment;
LoadRole role = loadRoleFromString(roleStr);
// Try to find the Thing
Thing *thing = m_thingManager->findConfiguredThing(thingId);
if (!thing) {
qCWarning(dcNymeaEnergy()) << "LoadAdapterRegistry: saved thing not found:"
<< thingId << "for role" << roleStr;
RoleEntry &entry = m_entries[role];
entry.rawParams = params;
entry.enabled = enabled;
entry.lastError = QStringLiteral("Thing not found: %1").arg(thingId.toString());
emit setupStatusChanged(setupStatus());
continue;
}
const QString result = assignThing(role, thingId, params);
if (result.startsWith(QLatin1String("error:"))) {
qCWarning(dcNymeaEnergy()) << "LoadAdapterRegistry: failed to restore assignment:"
<< result;
m_entries[role].lastError = result;
} else {
m_entries[role].enabled = enabled;
}
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
QList<ThingInfo> LoadAdapterRegistry::compatibleThings(LoadRole role) const
{
QList<ThingInfo> result;
if (!m_thingManager)
return result;
const QStringList required = loadRoleCompatibleInterfaces().value(role);
foreach (Thing *thing, m_thingManager->configuredThings()) {
const QStringList ifaces = thing->thingClass().interfaces();
bool compatible = false;
foreach (const QString &iface, required) {
if (ifaces.contains(iface)) { compatible = true; break; }
}
if (!compatible)
continue;
ThingInfo info;
info.thingId = thing->id();
info.displayName = thing->name();
info.pluginName = thing->pluginId().toString();
info.interfaces = ifaces;
result.append(info);
}
return result;
}
QString LoadAdapterRegistry::assignThing(LoadRole role,
const ThingId &thingId,
const QVariantMap &params)
{
if (!m_thingManager) {
return QStringLiteral("error: no ThingManager");
}
Thing *thing = m_thingManager->findConfiguredThing(thingId);
if (!thing) {
return QStringLiteral("error: Thing not found: %1").arg(thingId.toString());
}
const QStringList ifaces = thing->thingClass().interfaces();
const QString adapterType = detectAdapterType(role, ifaces);
if (adapterType.isEmpty()) {
return QStringLiteral("error: No compatible adapter for role %1 and interfaces %2")
.arg(loadRoleToString(role))
.arg(ifaces.join(QLatin1String(", ")));
}
// Destroy previous adapter for this role
RoleEntry &entry = m_entries[role];
if (entry.adapter) {
entry.adapter->deleteLater();
entry.adapter = nullptr;
}
// Create new adapter
ILoadAdapter *adapter = createAdapter(role, thingId, adapterType, params);
if (!adapter) {
entry.lastError = QStringLiteral("Failed to create adapter of type: %1").arg(adapterType);
return QStringLiteral("error: ") + entry.lastError;
}
// Forward reachability changes → setupStatusChanged
connect(adapter, &ILoadAdapter::reachabilityChanged, this, [this](bool) {
emit setupStatusChanged(setupStatus());
});
entry.adapter = adapter;
entry.rawParams = params;
entry.rawParams.insert(QStringLiteral("role"), loadRoleToString(role));
entry.rawParams.insert(QStringLiteral("thingId"), thingId.toString());
entry.rawParams.insert(QStringLiteral("adapterType"), adapterType);
entry.lastError.clear();
qCDebug(dcNymeaEnergy()) << "LoadAdapterRegistry: assigned"
<< loadRoleToString(role) << "" << thing->name()
<< "(adapter:" << adapterType << ")";
emit roleAssigned(role, thingId);
emit setupStatusChanged(setupStatus());
return adapterType;
}
void LoadAdapterRegistry::unassignRole(LoadRole role)
{
RoleEntry &entry = m_entries[role];
if (entry.adapter) {
entry.adapter->deleteLater();
entry.adapter = nullptr;
}
entry.rawParams.clear();
entry.lastError.clear();
emit roleUnassigned(role);
emit setupStatusChanged(setupStatus());
}
ILoadAdapter *LoadAdapterRegistry::adapterForRole(LoadRole role) const
{
return m_entries.value(role).adapter;
}
bool LoadAdapterRegistry::isRoleAssigned(LoadRole role) const
{
return m_entries.value(role).adapter != nullptr;
}
bool LoadAdapterRegistry::isRoleEnabled(LoadRole role) const
{
return m_entries.value(role).enabled;
}
void LoadAdapterRegistry::setRoleEnabled(LoadRole role, bool enabled)
{
RoleEntry &entry = m_entries[role];
if (entry.enabled == enabled)
return;
entry.enabled = enabled;
emit roleEnabledChanged(role, enabled);
emit setupStatusChanged(setupStatus());
}
void LoadAdapterRegistry::testConnection(LoadRole role)
{
ILoadAdapter *adapter = m_entries.value(role).adapter;
if (!adapter) {
emit connectionTestResult(role, false, QStringLiteral("Role not assigned"));
return;
}
connect(adapter, &ILoadAdapter::testResult, this, [this, role](bool ok, const QString &msg) {
emit connectionTestResult(role, ok, msg);
}, Qt::SingleShotConnection);
adapter->testConnection();
}
SetupStatus LoadAdapterRegistry::setupStatus() const
{
SetupStatus status;
status.configuredCount = 0;
status.errorCount = 0;
status.allEnabledRolesOk = true;
for (LoadRole role : allLoadRoles()) {
RoleStatus rs = buildRoleStatus(role);
status.roles.append(rs);
if (rs.assigned)
status.configuredCount++;
if (!rs.lastError.isEmpty())
status.errorCount++;
if (rs.enabled && rs.assigned && !rs.reachable)
status.allEnabledRolesOk = false;
if (rs.enabled && !rs.assigned)
status.allEnabledRolesOk = false;
}
return status;
}
QList<QVariantMap> LoadAdapterRegistry::rawAssignments() const
{
QList<QVariantMap> result;
for (auto it = m_entries.constBegin(); it != m_entries.constEnd(); ++it) {
const RoleEntry &e = it.value();
if (e.adapter || !e.rawParams.isEmpty()) {
QVariantMap m = e.rawParams;
m.insert(QStringLiteral("enabled"), e.enabled);
result.append(m);
}
}
return result;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
void LoadAdapterRegistry::onThingAdded(Thing *thing)
{
if (!thing)
return;
const QStringList ifaces = thing->thingClass().interfaces();
const QMap<LoadRole, QStringList> compatMap = loadRoleCompatibleInterfaces();
for (auto it = compatMap.constBegin(); it != compatMap.constEnd(); ++it) {
LoadRole role = it.key();
if (isRoleAssigned(role))
continue; // already assigned, no need to advertise
bool compatible = false;
foreach (const QString &iface, it.value()) {
if (ifaces.contains(iface)) { compatible = true; break; }
}
if (!compatible)
continue;
ThingInfo info;
info.thingId = thing->id();
info.displayName = thing->name();
info.pluginName = thing->pluginId().toString();
info.interfaces = ifaces;
qCDebug(dcNymeaEnergy()) << "LoadAdapterRegistry: new Thing" << thing->name()
<< "compatible with role" << loadRoleToString(role);
emit thingBecameCompatible(role, info);
}
}
ILoadAdapter *LoadAdapterRegistry::createAdapter(LoadRole role,
const ThingId &thingId,
const QString &adapterType,
const QVariantMap &params)
{
if (adapterType == QLatin1String("relay")) {
double nominalW = params.value(QStringLiteral("nominalPowerW"), 2000.0).toDouble();
return new RelayAdapter(m_thingManager, thingId, role, nominalW, this);
}
if (adapterType == QLatin1String("sgready")) {
ThingId relay1 = ThingId(params.value(QStringLiteral("relay1ThingId")).toString());
ThingId relay2 = ThingId(params.value(QStringLiteral("relay2ThingId")).toString());
double normalW = params.value(QStringLiteral("normalPowerW"), 1500.0).toDouble();
return new SgReadyAdapter(m_thingManager, relay1, relay2, normalW, this);
}
if (adapterType == QLatin1String("evcharger")) {
int phases = params.value(QStringLiteral("phases"), 1).toInt();
int minA = params.value(QStringLiteral("minA"), 6).toInt();
int maxA = params.value(QStringLiteral("maxA"), 32).toInt();
return new EvChargerAdapter(m_thingManager, thingId, phases, minA, maxA, this);
}
if (adapterType == QLatin1String("battery")) {
double cap = params.value(QStringLiteral("capacityKwh"), 10.0).toDouble();
double maxCharge = params.value(QStringLiteral("maxChargeW"), 5000.0).toDouble();
double maxDisch = params.value(QStringLiteral("maxDischargeW"),5000.0).toDouble();
return new BatteryAdapter(m_thingManager, thingId, cap, maxCharge, maxDisch, this);
}
if (adapterType == QLatin1String("readonly")) {
// Read-only meters: no adapter needed — just store the ThingId
qCDebug(dcNymeaEnergy()) << "LoadAdapterRegistry: read-only role" << loadRoleToString(role)
<< "— no adapter created (meter only)";
return nullptr;
}
qCWarning(dcNymeaEnergy()) << "LoadAdapterRegistry: unknown adapterType:" << adapterType;
return nullptr;
}
RoleStatus LoadAdapterRegistry::buildRoleStatus(LoadRole role) const
{
RoleStatus rs;
rs.role = role;
const RoleEntry &entry = m_entries.value(role);
rs.enabled = entry.enabled;
rs.lastError = entry.lastError;
if (entry.adapter) {
rs.assigned = true;
rs.assignedThingId = entry.adapter->thingId();
rs.adapterType = entry.adapter->adapterId();
rs.reachable = entry.adapter->isReachable();
// Try to get display name from ThingManager
if (m_thingManager) {
Thing *t = m_thingManager->findConfiguredThing(rs.assignedThingId);
if (t)
rs.assignedThingName = t->name();
}
} else if (!entry.rawParams.isEmpty()) {
// Assignment failed (Thing not found at startup)
rs.assigned = true;
rs.assignedThingId = ThingId(entry.rawParams.value(QStringLiteral("thingId")).toString());
rs.adapterType = entry.rawParams.value(QStringLiteral("adapterType")).toString();
rs.reachable = false;
}
return rs;
}

View File

@ -0,0 +1,151 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef LOADADAPTERREGISTRY_H
#define LOADADAPTERREGISTRY_H
#include <QObject>
#include <QHash>
#include <QList>
#include <QVariantMap>
#include <QString>
#include <integrations/thingmanager.h>
#include "types/loadrole.h"
#include "adapters/iloadapter.h"
class AdapterSettings;
// Summarises a nymea Thing that is compatible with a given role.
struct ThingInfo {
ThingId thingId;
QString displayName;
QString pluginName;
QStringList interfaces;
};
// Per-role status, serialisable to JSON-RPC.
struct RoleStatus {
LoadRole role;
bool enabled = false;
bool assigned = false;
ThingId assignedThingId;
QString assignedThingName;
bool reachable = false;
QString adapterType;
QString lastError;
};
// Aggregated setup status across all roles.
struct SetupStatus {
QList<RoleStatus> roles;
bool allEnabledRolesOk = false;
int configuredCount = 0;
int errorCount = 0;
};
// LoadAdapterRegistry maps installer-visible roles to nymea Things and
// creates the appropriate ILoadAdapter for each assignment.
//
// Usage:
// LoadAdapterRegistry *reg = new LoadAdapterRegistry(thingManager, this);
// reg->loadFromSettings(adapterSettings);
// reg->assignThing(LoadRole::DHW, dhwThingId, {});
// reg->adapterForRole(LoadRole::DHW)->applyPower(2000);
class LoadAdapterRegistry : public QObject
{
Q_OBJECT
public:
explicit LoadAdapterRegistry(ThingManager *thingManager, QObject *parent = nullptr);
// Load saved assignments from AdapterSettings.
// Call once after construction; safe to call with nullptr (no-op).
void loadFromSettings(AdapterSettings *settings);
// List all configured Things compatible with the given role.
// Filters by the required interfaces for the role.
QList<ThingInfo> compatibleThings(LoadRole role) const;
// Assign a Thing to a role.
// 'params' is adapter-specific:
// relay: { nominalPowerW: double }
// sgready: { relay1ThingId: uuid, relay2ThingId: uuid, normalPowerW: double }
// evcharger:{ phases: int, minA: int, maxA: int }
// battery: { capacityKwh: double, maxChargeW: double, maxDischargeW: double }
// Returns the detected adapter type, or an error string prefixed with "error:".
QString assignThing(LoadRole role, const ThingId &thingId, const QVariantMap &params);
// Unassign a role; destroys the underlying adapter.
void unassignRole(LoadRole role);
// Return the adapter for a role, or nullptr if not assigned.
ILoadAdapter *adapterForRole(LoadRole role) const;
bool isRoleAssigned(LoadRole role) const;
bool isRoleEnabled(LoadRole role) const;
void setRoleEnabled(LoadRole role, bool enabled);
// Initiate a connection test for the role's adapter.
// Result is delivered via connectionTestResult() signal.
void testConnection(LoadRole role);
// Current setup status across all roles.
SetupStatus setupStatus() const;
// Raw assignment maps for persistence (one map per assigned role).
QList<QVariantMap> rawAssignments() const;
// Static: determine adapter type from role + Thing interfaces (no ThingManager needed).
// Returns "relay", "sgready", "evcharger", "battery", or "readonly".
static QString detectAdapterType(LoadRole role, const QStringList &interfaces);
signals:
void roleAssigned(LoadRole role, ThingId thingId);
void roleUnassigned(LoadRole role);
void roleEnabledChanged(LoadRole role, bool enabled);
void connectionTestResult(LoadRole role, bool success, const QString &message);
void setupStatusChanged(const SetupStatus &status);
void thingBecameCompatible(LoadRole role, const ThingInfo &info);
private:
struct RoleEntry {
bool enabled = false;
ILoadAdapter *adapter = nullptr;
QVariantMap rawParams;
QString lastError;
};
void onThingAdded(Thing *thing);
ILoadAdapter *createAdapter(LoadRole role,
const ThingId &thingId,
const QString &adapterType,
const QVariantMap &params);
RoleStatus buildRoleStatus(LoadRole role) const;
ThingManager *m_thingManager;
QHash<LoadRole, RoleEntry> m_entries;
};
#endif // LOADADAPTERREGISTRY_H

View File

@ -0,0 +1,151 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "relayadapter.h"
#include "types/action.h"
#include "integrations/thingmanager.h"
#include "integrations/thingactioninfo.h"
#include <QLoggingCategory>
#include <QTimer>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
RelayAdapter::RelayAdapter(ThingManager *thingManager,
const ThingId &thingId,
LoadRole role,
double nominalPowerW,
QObject *parent)
: ILoadAdapter(parent),
m_thingManager(thingManager),
m_role(role),
m_nominalPowerW(nominalPowerW)
{
m_thing = thingManager->findConfiguredThing(thingId);
if (!m_thing)
qCWarning(dcNymeaEnergy()) << "RelayAdapter: thing not found:" << thingId;
}
QString RelayAdapter::adapterId() const
{
return QStringLiteral("relay");
}
LoadRole RelayAdapter::role() const
{
return m_role;
}
ThingId RelayAdapter::thingId() const
{
return m_thing ? m_thing->id() : ThingId();
}
void RelayAdapter::applyPower(double targetPowerW)
{
if (!m_thing) {
qCWarning(dcNymeaEnergy()) << "RelayAdapter::applyPower: no thing available";
emit powerApplied(0, false);
return;
}
setPower(targetPowerW > 0);
}
void RelayAdapter::testConnection()
{
if (!m_thing) {
emit testResult(false, QStringLiteral("Thing not found"));
return;
}
// Toggle on → wait 2s → toggle off → report success
setPower(true);
QTimer::singleShot(2000, this, [this]() {
setPower(false);
emit testResult(true, QStringLiteral("Connection test OK"));
});
}
double RelayAdapter::currentPowerW() const
{
return m_relayOn ? m_nominalPowerW : 0.0;
}
bool RelayAdapter::isReachable() const
{
if (!m_thing)
return false;
// If the ThingClass has a "connected" state, use it; otherwise assume reachable.
foreach (const StateType &st, m_thing->thingClass().stateTypes()) {
if (st.name() == QLatin1String("connected"))
return m_thing->stateValue(st.id()).toBool();
}
return true;
}
void RelayAdapter::setPower(bool on)
{
if (!m_thing)
return;
// Find the "power" action type (relay interface)
ActionType powerActionType;
foreach (const ActionType &at, m_thing->thingClass().actionTypes()) {
if (at.name() == QLatin1String("power")) {
powerActionType = at;
break;
}
}
if (powerActionType.id().isNull()) {
qCWarning(dcNymeaEnergy()) << "RelayAdapter: no 'power' action type on thing"
<< m_thing->name();
emit powerApplied(on ? m_nominalPowerW : 0, false);
return;
}
// Find the "power" param type
ParamTypeId powerParamTypeId;
foreach (const ParamType &pt, powerActionType.paramTypes()) {
if (pt.name() == QLatin1String("power")) {
powerParamTypeId = pt.id();
break;
}
}
Action action(powerActionType.id(), m_thing->id(), Action::TriggeredByRule);
action.setParams(ParamList() << Param(powerParamTypeId, on));
ThingActionInfo *info = m_thingManager->executeAction(action);
connect(info, &ThingActionInfo::finished, this, [this, on, info]() {
bool ok = (info->status() == Thing::ThingErrorNoError);
if (ok)
m_relayOn = on;
emit powerApplied(on ? m_nominalPowerW : 0, ok);
if (!ok)
qCWarning(dcNymeaEnergy()) << "RelayAdapter: action failed on" << m_thing->name();
});
}

View File

@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef RELAYADAPTER_H
#define RELAYADAPTER_H
#include "adapters/iloadapter.h"
#include <integrations/thingmanager.h>
// Relay-based adapter for DHW tanks and simple heat pumps.
//
// Maps the power setpoint to a binary on/off relay action.
// A nominal power (watts) is used to report currentPowerW() when the relay is on.
class RelayAdapter : public ILoadAdapter
{
Q_OBJECT
public:
explicit RelayAdapter(ThingManager *thingManager,
const ThingId &thingId,
LoadRole role,
double nominalPowerW = 2000.0,
QObject *parent = nullptr);
QString adapterId() const override;
LoadRole role() const override;
ThingId thingId() const override;
void applyPower(double targetPowerW) override;
void testConnection() override;
double currentPowerW() const override;
bool isReachable() const override;
private:
void setPower(bool on);
ThingManager *m_thingManager = nullptr;
Thing *m_thing = nullptr;
LoadRole m_role;
double m_nominalPowerW;
bool m_relayOn = false;
};
#endif // RELAYADAPTER_H

View File

@ -0,0 +1,176 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "sgreadyadapter.h"
#include "types/action.h"
#include "integrations/thingmanager.h"
#include "integrations/thingactioninfo.h"
#include <QLoggingCategory>
#include <QTimer>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
SgReadyAdapter::SgReadyAdapter(ThingManager *thingManager,
const ThingId &relay1ThingId,
const ThingId &relay2ThingId,
double normalPowerW,
QObject *parent)
: ILoadAdapter(parent),
m_thingManager(thingManager),
m_normalPowerW(normalPowerW)
{
m_relay1 = thingManager->findConfiguredThing(relay1ThingId);
m_relay2 = thingManager->findConfiguredThing(relay2ThingId);
if (!m_relay1)
qCWarning(dcNymeaEnergy()) << "SgReadyAdapter: relay1 not found:" << relay1ThingId;
if (!m_relay2)
qCWarning(dcNymeaEnergy()) << "SgReadyAdapter: relay2 not found:" << relay2ThingId;
}
QString SgReadyAdapter::adapterId() const
{
return QStringLiteral("sgready");
}
LoadRole SgReadyAdapter::role() const
{
return LoadRole::HeatPump;
}
ThingId SgReadyAdapter::thingId() const
{
return m_relay1 ? m_relay1->id() : ThingId();
}
void SgReadyAdapter::applyPower(double targetPowerW)
{
SgMode mode;
if (targetPowerW <= 0)
mode = SgMode::SG1_Blocked;
else if (targetPowerW < m_normalPowerW)
mode = SgMode::SG2_Normal;
else if (qFuzzyCompare(targetPowerW, m_normalPowerW))
mode = SgMode::SG3_Surplus;
else
mode = SgMode::SG4_Boost;
applyMode(mode);
}
void SgReadyAdapter::testConnection()
{
if (!m_relay1 || !m_relay2) {
emit testResult(false, QStringLiteral("One or both relay Things not found"));
return;
}
// SG2 → wait 1s → SG3 → wait 1s → SG2 → emit success
applyMode(SgMode::SG2_Normal);
QTimer::singleShot(1000, this, [this]() {
applyMode(SgMode::SG3_Surplus);
QTimer::singleShot(1000, this, [this]() {
applyMode(SgMode::SG2_Normal);
emit testResult(true, QStringLiteral("SG-Ready connection test OK"));
});
});
}
double SgReadyAdapter::currentPowerW() const
{
switch (m_currentMode) {
case SgMode::SG1_Blocked: return 0;
case SgMode::SG2_Normal: return m_normalPowerW;
case SgMode::SG3_Surplus: return m_normalPowerW * 1.2;
case SgMode::SG4_Boost: return m_normalPowerW * 1.5;
}
return 0;
}
bool SgReadyAdapter::isReachable() const
{
auto checkConnected = [](Thing *thing) -> bool {
if (!thing) return false;
foreach (const StateType &st, thing->thingClass().stateTypes()) {
if (st.name() == QLatin1String("connected"))
return thing->stateValue(st.id()).toBool();
}
return true;
};
return checkConnected(m_relay1) && checkConnected(m_relay2);
}
void SgReadyAdapter::applyMode(SgMode mode)
{
// SG-Ready contact matrix:
// SG1: r1=OFF r2=OFF (blocked)
// SG2: r1=OFF r2=ON (normal)
// SG3: r1=ON r2=OFF (surplus)
// SG4: r1=ON r2=ON (boost)
bool r1on = (mode == SgMode::SG3_Surplus || mode == SgMode::SG4_Boost);
bool r2on = (mode == SgMode::SG2_Normal || mode == SgMode::SG4_Boost);
setRelayPower(m_relay1, r1on);
setRelayPower(m_relay2, r2on);
m_currentMode = mode;
qCDebug(dcNymeaEnergy()) << "SgReadyAdapter: mode" << static_cast<int>(mode)
<< "r1=" << r1on << "r2=" << r2on;
emit powerApplied(currentPowerW(), true);
}
void SgReadyAdapter::setRelayPower(Thing *thing, bool on)
{
if (!thing)
return;
ActionType powerActionType;
foreach (const ActionType &at, thing->thingClass().actionTypes()) {
if (at.name() == QLatin1String("power")) {
powerActionType = at;
break;
}
}
if (powerActionType.id().isNull()) {
qCWarning(dcNymeaEnergy()) << "SgReadyAdapter: no 'power' action on" << thing->name();
return;
}
ParamTypeId powerParamTypeId;
foreach (const ParamType &pt, powerActionType.paramTypes()) {
if (pt.name() == QLatin1String("power")) {
powerParamTypeId = pt.id();
break;
}
}
Action action(powerActionType.id(), thing->id(), Action::TriggeredByRule);
action.setParams(ParamList() << Param(powerParamTypeId, on));
m_thingManager->executeAction(action);
}

View File

@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef SGREADYADAPTER_H
#define SGREADYADAPTER_H
#include "adapters/iloadapter.h"
#include <integrations/thingmanager.h>
// SG-Ready adapter for heat pumps controlled via two relay contacts.
//
// SG-Ready uses 2 binary relay contacts to select operating mode:
// SG1 (blocked): relay1=OFF, relay2=OFF — forced off
// SG2 (normal): relay1=OFF, relay2=ON — normal operation
// SG3 (surplus): relay1=ON, relay2=OFF — raise setpoint (surplus mode)
// SG4 (boost): relay1=ON, relay2=ON — forced max heat
//
// Power mapping:
// applyPower(0) → SG1 (blocked)
// applyPower(<normalPowerW) → SG2 (normal)
// applyPower(normalPowerW) → SG3 (surplus)
// applyPower(>normalPowerW) → SG4 (boost)
class SgReadyAdapter : public ILoadAdapter
{
Q_OBJECT
public:
explicit SgReadyAdapter(ThingManager *thingManager,
const ThingId &relay1ThingId,
const ThingId &relay2ThingId,
double normalPowerW = 1500.0,
QObject *parent = nullptr);
QString adapterId() const override;
LoadRole role() const override;
ThingId thingId() const override; // returns relay1 ThingId
void applyPower(double targetPowerW) override;
void testConnection() override;
double currentPowerW() const override;
bool isReachable() const override;
private:
enum class SgMode { SG1_Blocked, SG2_Normal, SG3_Surplus, SG4_Boost };
void applyMode(SgMode mode);
void setRelayPower(Thing *thing, bool on);
ThingManager *m_thingManager = nullptr;
Thing *m_relay1 = nullptr;
Thing *m_relay2 = nullptr;
double m_normalPowerW;
SgMode m_currentMode = SgMode::SG2_Normal;
};
#endif // SGREADYADAPTER_H

View File

@ -45,11 +45,19 @@ HEADERS += \
$$PWD/types/chargingschedule.h \
$$PWD/types/energytimeslot.h \
$$PWD/types/flexibleload.h \
$$PWD/types/loadrole.h \
$$PWD/types/manualslotconfig.h \
$$PWD/types/schedulerconfig.h \
$$PWD/types/scoreentry.h \
$$PWD/types/smartchargingstate.h \
$$PWD/types/timeframe.h \
$$PWD/adapters/iloadapter.h \
$$PWD/adapters/relayadapter.h \
$$PWD/adapters/sgreadyadapter.h \
$$PWD/adapters/evchargeradapter.h \
$$PWD/adapters/batteryadapter.h \
$$PWD/adapters/loadadapterregistry.h \
$$PWD/adapters/adaptersettings.h \
SOURCES += \
$$PWD/energymanagerconfiguration.cpp \
@ -77,3 +85,9 @@ SOURCES += \
$$PWD/types/scoreentry.cpp \
$$PWD/types/smartchargingstate.cpp \
$$PWD/types/timeframe.cpp \
$$PWD/adapters/relayadapter.cpp \
$$PWD/adapters/sgreadyadapter.cpp \
$$PWD/adapters/evchargeradapter.cpp \
$$PWD/adapters/batteryadapter.cpp \
$$PWD/adapters/loadadapterregistry.cpp \
$$PWD/adapters/adaptersettings.cpp \

View File

@ -29,6 +29,9 @@
#include "energymanagerconfiguration.h"
#include "spotmarket/spotmarketmanager.h"
#include "schedulingstrategies/manualstrategy.h"
#include "adapters/loadadapterregistry.h"
#include "adapters/adaptersettings.h"
#include "types/loadrole.h"
#include "plugininfo.h"
@ -47,12 +50,27 @@ void EnergyPluginNymea::init()
SpotMarketManager *spotMarketManager = new SpotMarketManager(networkManager, this);
SmartChargingManager *chargingManager = new SmartChargingManager(energyManager(), thingManager(), spotMarketManager, configuration, this);
SchedulerManager *schedulerManager = new SchedulerManager(spotMarketManager, energyManager(), thingManager(), this);
// Adapter registry: maps installer roles to nymea Things
AdapterSettings *adapterSettings = new AdapterSettings(this);
LoadAdapterRegistry *adapterRegistry = new LoadAdapterRegistry(thingManager(), this);
adapterRegistry->loadFromSettings(adapterSettings);
// Auto-save role assignments whenever they change
auto saveAdapters = [adapterSettings, adapterRegistry]() {
adapterSettings->save(adapterRegistry->rawAssignments());
};
connect(adapterRegistry, &LoadAdapterRegistry::roleAssigned, this, [saveAdapters](LoadRole, ThingId) { saveAdapters(); });
connect(adapterRegistry, &LoadAdapterRegistry::roleUnassigned, this, [saveAdapters](LoadRole) { saveAdapters(); });
connect(adapterRegistry, &LoadAdapterRegistry::roleEnabledChanged, this, [saveAdapters](LoadRole, bool) { saveAdapters(); });
SchedulerManager *schedulerManager = new SchedulerManager(
spotMarketManager, energyManager(), thingManager(), adapterRegistry, this);
// Community-tier strategies (always available, no feature flag)
schedulerManager->registerStrategy(new ManualStrategy(this));
jsonRpcServer()->registerExperienceHandler(
new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, schedulerManager, this),
0, 11);
new NymeaEnergyJsonHandler(spotMarketManager, chargingManager,
schedulerManager, adapterRegistry, this),
0, 12);
}

View File

@ -28,6 +28,8 @@
#include "spotmarket/spotmarketmanager.h"
#include "schedulermanager.h"
#include "schedulingstrategies/manualstrategy.h"
#include "adapters/loadadapterregistry.h"
#include "types/loadrole.h"
#include <energymanager.h>
@ -37,11 +39,13 @@ Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager,
SmartChargingManager *smartChargingManager,
SchedulerManager *schedulerManager,
LoadAdapterRegistry *adapterRegistry,
QObject *parent):
JsonHandler{parent},
m_spotMarketManager{spotMarketManager},
m_smartChargingManager{smartChargingManager},
m_schedulerManager{schedulerManager}
m_schedulerManager{schedulerManager},
m_adapterRegistry{adapterRegistry}
{
registerEnum<ChargingInfo::ChargingMode>();
@ -448,6 +452,107 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
params.insert("reason", enumValueName(String));
registerNotification("ManualSlotActivated", description, params);
}
// --- Installer Setup API (v12) ---
if (m_adapterRegistry) {
params.clear(); returns.clear();
description = "Get the current installer setup status: which roles are assigned, "
"enabled, reachable and what adapter type is used.";
returns.insert("setupStatus", QVariantMap());
registerMethod("GetSetupStatus", description, params, returns,
Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "List all nymea Things that are compatible with the given installer role. "
"Role must be one of: EVCharger, DHW, HeatPump, Battery, SolarMeter, GridMeter.";
params.insert("role", enumValueName(String));
returns.insert("role", enumValueName(String));
returns.insert("things", QVariantList());
registerMethod("GetCompatibleThings", description, params, returns,
Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Assign a nymea Thing to an installer role. "
"The adapter type is auto-detected from the Thing's interfaces. "
"Optional params: nominalPowerW (relay), normalPowerW+relay1/2ThingId (sgready), "
"phases/minA/maxA (evcharger), capacityKwh/maxChargeW/maxDischargeW (battery).";
params.insert("role", enumValueName(String));
params.insert("thingId", enumValueName(Uuid));
params.insert("o:params", QVariantMap());
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
returns.insert("adapterType", enumValueName(String));
returns.insert("detectedParams", QVariantMap());
registerMethod("AssignThingToRole", description, params, returns,
Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Unassign the currently assigned Thing from the given installer role.";
params.insert("role", enumValueName(String));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("UnassignRole", description, params, returns,
Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Enable or disable an installer role. "
"Disabled roles are not acted upon by the Scheduler even if assigned.";
params.insert("role", enumValueName(String));
params.insert("enabled", enumValueName(Bool));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetRoleEnabled", description, params, returns,
Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Initiate a connection test for the assigned Thing of the given role. "
"Result is delivered via the ConnectionTestResult push notification.";
params.insert("role", enumValueName(String));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("TestRoleConnection", description, params, returns,
Types::PermissionScopeControlThings);
// Push notifications
params.clear();
description = "Emitted when the installer setup status changes "
"(assignment, enable/disable, reachability).";
params.insert("setupStatus", QVariantMap());
registerNotification("SetupStatusChanged", description, params);
params.clear();
description = "Emitted with the result of a TestRoleConnection call.";
params.insert("role", enumValueName(String));
params.insert("success", enumValueName(Bool));
params.insert("message", enumValueName(String));
registerNotification("ConnectionTestResult", description, params);
params.clear();
description = "Emitted when a newly added nymea Thing is compatible with an unassigned role. "
"The UI can use this to prompt the installer.";
params.insert("role", enumValueName(String));
params.insert("thing", QVariantMap());
registerNotification("ThingBecameCompatible", description, params);
// Wire adapter registry signals to notifications
connect(m_adapterRegistry, &LoadAdapterRegistry::setupStatusChanged,
this, [this](const SetupStatus &status) {
emit SetupStatusChanged({{"setupStatus", setupStatusToVariant(status)}});
});
connect(m_adapterRegistry, &LoadAdapterRegistry::connectionTestResult,
this, [this](LoadRole role, bool success, const QString &message) {
QVariantMap p;
p.insert("role", loadRoleToString(role));
p.insert("success", success);
p.insert("message", message);
emit ConnectionTestResult(p);
});
connect(m_adapterRegistry, &LoadAdapterRegistry::thingBecameCompatible,
this, [this](LoadRole role, const ThingInfo &info) {
QVariantMap p;
p.insert("role", loadRoleToString(role));
p.insert("thing", thingInfoToVariant(info));
emit ThingBecameCompatible(p);
});
}
}
QString NymeaEnergyJsonHandler::name() const
@ -895,3 +1000,132 @@ QVariantMap NymeaEnergyJsonHandler::buildTimelineSummary(const QList<EnergyTimeS
summary.insert("totalGridExportKwh", totalExportKwh);
return summary;
}
// ---------------------------------------------------------------------------
// Installer Setup API implementations — NymeaEnergy v12
// ---------------------------------------------------------------------------
JsonReply *NymeaEnergyJsonHandler::GetSetupStatus(const QVariantMap &params)
{
Q_UNUSED(params)
if (!m_adapterRegistry)
return createReply({{"setupStatus", QVariantMap()}});
return createReply({{"setupStatus", setupStatusToVariant(m_adapterRegistry->setupStatus())}});
}
JsonReply *NymeaEnergyJsonHandler::GetCompatibleThings(const QVariantMap &params)
{
const QString roleStr = params.value("role").toString();
if (roleStr.isEmpty() || !m_adapterRegistry)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorMissingParameter)},
{"role", roleStr}, {"things", QVariantList()}});
LoadRole role = loadRoleFromString(roleStr);
QVariantList thingList;
foreach (const ThingInfo &info, m_adapterRegistry->compatibleThings(role))
thingList.append(thingInfoToVariant(info));
return createReply({{"role", roleStr}, {"things", thingList}});
}
JsonReply *NymeaEnergyJsonHandler::AssignThingToRole(const QVariantMap &params)
{
if (!m_adapterRegistry)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
{"adapterType", QString()}, {"detectedParams", QVariantMap()}});
const QString roleStr = params.value("role").toString();
const ThingId thingId = ThingId(params.value("thingId").toUuid());
const QVariantMap extra = params.value("params").toMap();
if (roleStr.isEmpty() || thingId.isNull())
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorMissingParameter)},
{"adapterType", QString()}, {"detectedParams", QVariantMap()}});
LoadRole role = loadRoleFromString(roleStr);
const QString result = m_adapterRegistry->assignThing(role, thingId, extra);
if (result.startsWith(QLatin1String("error:")))
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
{"adapterType", QString()}, {"detectedParams", QVariantMap()}});
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)},
{"adapterType", result},
{"detectedParams", extra}});
}
JsonReply *NymeaEnergyJsonHandler::UnassignRole(const QVariantMap &params)
{
const QString roleStr = params.value("role").toString();
if (roleStr.isEmpty() || !m_adapterRegistry)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorMissingParameter)}});
m_adapterRegistry->unassignRole(loadRoleFromString(roleStr));
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
}
JsonReply *NymeaEnergyJsonHandler::SetRoleEnabled(const QVariantMap &params)
{
const QString roleStr = params.value("role").toString();
if (roleStr.isEmpty() || !m_adapterRegistry)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorMissingParameter)}});
bool enabled = params.value("enabled").toBool();
m_adapterRegistry->setRoleEnabled(loadRoleFromString(roleStr), enabled);
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
}
JsonReply *NymeaEnergyJsonHandler::TestRoleConnection(const QVariantMap &params)
{
const QString roleStr = params.value("role").toString();
if (roleStr.isEmpty() || !m_adapterRegistry)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorMissingParameter)}});
if (!m_adapterRegistry->isRoleAssigned(loadRoleFromString(roleStr)))
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
m_adapterRegistry->testConnection(loadRoleFromString(roleStr));
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
}
// ---------------------------------------------------------------------------
// Installer Setup serialization helpers
// ---------------------------------------------------------------------------
QVariantMap NymeaEnergyJsonHandler::setupStatusToVariant(const SetupStatus &status) const
{
QVariantList roleList;
foreach (const RoleStatus &rs, status.roles)
roleList.append(roleStatusToVariant(rs));
QVariantMap m;
m.insert("roles", roleList);
m.insert("allEnabledRolesOk", status.allEnabledRolesOk);
m.insert("configuredCount", status.configuredCount);
m.insert("errorCount", status.errorCount);
return m;
}
QVariantMap NymeaEnergyJsonHandler::roleStatusToVariant(const RoleStatus &rs) const
{
QVariantMap m;
m.insert("role", loadRoleToString(rs.role));
m.insert("enabled", rs.enabled);
m.insert("assigned", rs.assigned);
m.insert("assignedThingId", rs.assignedThingId.isNull() ? QVariant() : QVariant::fromValue(rs.assignedThingId));
m.insert("assignedThingName", rs.assignedThingName);
m.insert("reachable", rs.reachable);
m.insert("adapterType", rs.adapterType);
m.insert("lastError", rs.lastError);
return m;
}
QVariantMap NymeaEnergyJsonHandler::thingInfoToVariant(const ThingInfo &info) const
{
QVariantMap m;
m.insert("thingId", info.thingId.isNull() ? QVariant() : QVariant::fromValue(info.thingId));
m.insert("displayName", info.displayName);
m.insert("pluginName", info.pluginName);
m.insert("interfaces", info.interfaces);
return m;
}

View File

@ -32,6 +32,7 @@
#include "types/scoreentry.h"
#include "types/energytimeslot.h"
#include "types/manualslotconfig.h"
#include "adapters/loadadapterregistry.h"
class SmartChargingManager;
class SpotMarketManager;
@ -44,6 +45,7 @@ public:
explicit NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager,
SmartChargingManager *smartChargingManager,
SchedulerManager *schedulerManager,
LoadAdapterRegistry *adapterRegistry = nullptr,
QObject *parent = nullptr);
QString name() const override;
@ -89,6 +91,14 @@ public:
Q_INVOKABLE JsonReply *RemoveManualSlot(const QVariantMap &params);
Q_INVOKABLE JsonReply *ClearManualSlots(const QVariantMap &params);
// --- Installer Setup API (NymeaEnergy v12) ---
Q_INVOKABLE JsonReply *GetSetupStatus(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetCompatibleThings(const QVariantMap &params);
Q_INVOKABLE JsonReply *AssignThingToRole(const QVariantMap &params);
Q_INVOKABLE JsonReply *UnassignRole(const QVariantMap &params);
Q_INVOKABLE JsonReply *SetRoleEnabled(const QVariantMap &params);
Q_INVOKABLE JsonReply *TestRoleConnection(const QVariantMap &params);
signals:
void PhasePowerLimitChanged(const QVariantMap &params);
void AcquisitionToleranceChanged(const QVariantMap &params);
@ -109,10 +119,16 @@ signals:
// Manual slot push notification
void ManualSlotActivated(const QVariantMap &params);
// Installer setup push notifications (v12)
void SetupStatusChanged(const QVariantMap &params);
void ConnectionTestResult(const QVariantMap &params);
void ThingBecameCompatible(const QVariantMap &params);
private:
SpotMarketManager *m_spotMarketManager;
SmartChargingManager *m_smartChargingManager = nullptr;
SchedulerManager *m_schedulerManager = nullptr;
LoadAdapterRegistry *m_adapterRegistry = nullptr;
void sendSpotMarketConfigurationChangedNotification();
QVariantMap buildTimelineSummary(const QList<EnergyTimeSlot> &timeline) const;
@ -123,6 +139,11 @@ private:
// Helper: access the registered ManualStrategy (null if not registered)
class ManualStrategy *manualStrategy() const;
// Installer setup serialization helpers
QVariantMap setupStatusToVariant(const SetupStatus &status) const;
QVariantMap roleStatusToVariant(const RoleStatus &rs) const;
QVariantMap thingInfoToVariant(const ThingInfo &info) const;
};
#endif // NYMEAENERGYJSONHANDLER_H

View File

@ -28,19 +28,24 @@
#include "schedulingstrategies/manualstrategy.h"
#include "schedulersettings.h"
#include "spotmarket/spotmarketmanager.h"
#include "adapters/loadadapterregistry.h"
#include "adapters/iloadapter.h"
#include "types/loadrole.h"
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
SchedulerManager::SchedulerManager(
SpotMarketManager *spotMarketManager,
EnergyManager *energyManager,
ThingManager *thingManager,
QObject *parent)
SpotMarketManager *spotMarketManager,
EnergyManager *energyManager,
ThingManager *thingManager,
LoadAdapterRegistry *adapterRegistry,
QObject *parent)
: QObject(parent),
m_spotMarketManager(spotMarketManager),
m_energyManager(energyManager),
m_thingManager(thingManager)
m_thingManager(thingManager),
m_adapterRegistry(adapterRegistry)
{
m_settings = new SchedulerSettings(this);
@ -393,15 +398,29 @@ QList<FlexibleLoad> SchedulerManager::collectLoads() const
void SchedulerManager::applyCurrentSlot(const EnergyTimeSlot &slot)
{
// In Phase 1, we log the decision. Actual hardware commands are issued by
// each specialist manager (SmartChargingManager, etc.) which listens to
// the timelineUpdated() signal and reads the current slot.
qCDebug(dcNymeaEnergy()) << "SchedulerManager: applying slot:"
<< slot.decisionReason
<< "EV=" << slot.allocatedToEV << "W"
<< "HP=" << slot.allocatedToHP << "W"
<< "DHW=" << slot.allocatedToDHW << "W"
<< "Bat=" << slot.allocatedToBattery << "W";
if (!m_adapterRegistry)
return;
// Apply DHW allocation
if (ILoadAdapter *a = m_adapterRegistry->adapterForRole(LoadRole::DHW))
a->applyPower(slot.allocatedToDHW);
// Apply HeatPump allocation
if (ILoadAdapter *a = m_adapterRegistry->adapterForRole(LoadRole::HeatPump))
a->applyPower(slot.allocatedToHP);
// Apply Battery allocation (positive = charge, negative = discharge)
if (ILoadAdapter *a = m_adapterRegistry->adapterForRole(LoadRole::Battery))
a->applyPower(slot.allocatedToBattery);
// EVCharger is managed by SmartChargingManager — skip here to avoid conflicts
}
ManualStrategy *SchedulerManager::findManualStrategy() const

View File

@ -42,6 +42,7 @@
#include <integrations/thingmanager.h>
class SpotMarketManager;
class LoadAdapterRegistry;
// SchedulerManager orchestrates the full energy scheduling pipeline.
//
@ -62,6 +63,7 @@ public:
SpotMarketManager *spotMarketManager,
EnergyManager *energyManager,
ThingManager *thingManager,
LoadAdapterRegistry *adapterRegistry = nullptr,
QObject *parent = nullptr
);
@ -135,10 +137,11 @@ private:
// Helper: return the registered ManualStrategy, or nullptr if not found
class ManualStrategy *findManualStrategy() const;
SpotMarketManager *m_spotMarketManager = nullptr;
EnergyManager *m_energyManager = nullptr;
ThingManager *m_thingManager = nullptr;
class SchedulerSettings *m_settings = nullptr;
SpotMarketManager *m_spotMarketManager = nullptr;
EnergyManager *m_energyManager = nullptr;
ThingManager *m_thingManager = nullptr;
LoadAdapterRegistry *m_adapterRegistry = nullptr;
class SchedulerSettings *m_settings = nullptr;
ISchedulingStrategy *m_activeStrategy = nullptr;
QList<ISchedulingStrategy *> m_strategies;

View File

@ -0,0 +1,109 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef LOADROLE_H
#define LOADROLE_H
#include <QString>
#include <QMap>
#include <QStringList>
#include "types/flexibleload.h"
// Installer-visible roles that can be assigned to nymea Things.
enum class LoadRole {
EVCharger,
DHW,
HeatPump,
Battery,
SolarMeter,
GridMeter
};
inline QString loadRoleToString(LoadRole role)
{
switch (role) {
case LoadRole::EVCharger: return QStringLiteral("EVCharger");
case LoadRole::DHW: return QStringLiteral("DHW");
case LoadRole::HeatPump: return QStringLiteral("HeatPump");
case LoadRole::Battery: return QStringLiteral("Battery");
case LoadRole::SolarMeter: return QStringLiteral("SolarMeter");
case LoadRole::GridMeter: return QStringLiteral("GridMeter");
}
return QStringLiteral("Unknown");
}
inline LoadRole loadRoleFromString(const QString &str)
{
if (str == QLatin1String("EVCharger")) return LoadRole::EVCharger;
if (str == QLatin1String("DHW")) return LoadRole::DHW;
if (str == QLatin1String("HeatPump")) return LoadRole::HeatPump;
if (str == QLatin1String("Battery")) return LoadRole::Battery;
if (str == QLatin1String("SolarMeter")) return LoadRole::SolarMeter;
if (str == QLatin1String("GridMeter")) return LoadRole::GridMeter;
return LoadRole::GridMeter; // fallback
}
// Required nymea interfaces for each role.
// A Thing must implement at least one of the listed interfaces to be eligible.
inline QMap<LoadRole, QStringList> loadRoleCompatibleInterfaces()
{
QMap<LoadRole, QStringList> map;
map.insert(LoadRole::EVCharger, QStringList() << QStringLiteral("evcharger"));
map.insert(LoadRole::DHW, QStringList() << QStringLiteral("smartmeterconsumer")
<< QStringLiteral("relay"));
map.insert(LoadRole::HeatPump, QStringList() << QStringLiteral("heating")
<< QStringLiteral("relay"));
map.insert(LoadRole::Battery, QStringList() << QStringLiteral("energystorage"));
map.insert(LoadRole::SolarMeter, QStringList() << QStringLiteral("energymeter"));
map.insert(LoadRole::GridMeter, QStringList() << QStringLiteral("energymeter"));
return map;
}
// Convenience mapping from LoadSource (scheduling domain) to LoadRole (installer domain).
inline LoadRole loadRoleFromLoadSource(LoadSource source)
{
switch (source) {
case LoadSource::SmartCharging: return LoadRole::EVCharger;
case LoadSource::HeatPump: return LoadRole::HeatPump;
case LoadSource::DHW: return LoadRole::DHW;
case LoadSource::Battery: return LoadRole::Battery;
default: return LoadRole::GridMeter;
}
}
// All installer roles, in display order.
inline QList<LoadRole> allLoadRoles()
{
return {
LoadRole::EVCharger,
LoadRole::DHW,
LoadRole::HeatPump,
LoadRole::Battery,
LoadRole::SolarMeter,
LoadRole::GridMeter
};
}
#endif // LOADROLE_H

View File

@ -24,6 +24,9 @@
#include "testscheduler.h"
#include <QSignalSpy>
#include <QDir>
#include <QFile>
#include <QCoreApplication>
#include <algorithm>
// Typical winter day electricity prices (EUR/kWh), hours 00:0023:00
@ -551,6 +554,127 @@ void TestScheduler::testManualStrategy_persistence()
"Restored slot must produce ManualSlot rule after round-trip");
}
// ---------------------------------------------------------------------------
// Test A — LoadAdapterRegistry::detectAdapterType (pure static, no ThingManager)
// ---------------------------------------------------------------------------
void TestScheduler::testDetectAdapterType()
{
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::EVCharger,
QStringList() << QStringLiteral("evcharger")),
QStringLiteral("evcharger"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::HeatPump,
QStringList() << QStringLiteral("heating")),
QStringLiteral("sgready"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::HeatPump,
QStringList() << QStringLiteral("relay")),
QStringLiteral("relay"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::DHW,
QStringList() << QStringLiteral("relay")),
QStringLiteral("relay"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::Battery,
QStringList() << QStringLiteral("energystorage")),
QStringLiteral("battery"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::SolarMeter,
QStringList() << QStringLiteral("energymeter")),
QStringLiteral("readonly"));
QCOMPARE(LoadAdapterRegistry::detectAdapterType(LoadRole::GridMeter,
QStringList() << QStringLiteral("energymeter")),
QStringLiteral("readonly"));
// Incompatible interface returns empty
QVERIFY(LoadAdapterRegistry::detectAdapterType(LoadRole::Battery,
QStringList() << QStringLiteral("relay")).isEmpty());
}
// ---------------------------------------------------------------------------
// Test B — AdapterSettings round-trip
// ---------------------------------------------------------------------------
void TestScheduler::testAdapterSettings_roundTrip()
{
// Use a temp file to avoid polluting any production settings
const QString tmpPath = QDir::tempPath()
+ QStringLiteral("/nymea_adapter_test_%1.conf")
.arg(QCoreApplication::applicationPid());
// Override path via environment variable
qputenv("NYMEA_ADAPTER_SETTINGS", tmpPath.toUtf8());
{
AdapterSettings s;
QVariantMap params;
params.insert(QStringLiteral("thingId"), QStringLiteral("11111111-1111-1111-1111-111111111111"));
params.insert(QStringLiteral("adapterType"), QStringLiteral("relay"));
params.insert(QStringLiteral("nominalPowerW"), 2000.0);
params.insert(QStringLiteral("enabled"), true);
s.setAssignment(LoadRole::DHW, params);
} // destructor flushes (already saved by setAssignment)
{
AdapterSettings s2;
const QList<QVariantMap> loaded = s2.assignments();
QCOMPARE(loaded.size(), 1);
QCOMPARE(loaded.first().value(QStringLiteral("role")).toString(),
QStringLiteral("DHW"));
QCOMPARE(loaded.first().value(QStringLiteral("adapterType")).toString(),
QStringLiteral("relay"));
QCOMPARE(loaded.first().value(QStringLiteral("nominalPowerW")).toDouble(), 2000.0);
}
// Cleanup
QFile::remove(tmpPath);
qputenv("NYMEA_ADAPTER_SETTINGS", QByteArray());
}
// ---------------------------------------------------------------------------
// Test C — SchedulerManager with null registry does not crash
// ---------------------------------------------------------------------------
void TestScheduler::testSchedulerManager_nullRegistry_noCrash()
{
// SchedulerManager(nullptr registry) must not crash on construction or forceRecompute().
// It will still compute a 24-slot timeline (stub forecast, default strategy).
SchedulerManager mgr(nullptr, nullptr, nullptr, nullptr);
// forceRecompute is called via singleShot in the constructor; give the event loop a tick
QCoreApplication::processEvents();
// The manager builds a 24-slot forecast even with all null managers.
// The registry being nullptr means applyCurrentSlot() is a no-op — verify no crash.
QVERIFY2(mgr.planHealth() == QLatin1String("ok") ||
mgr.planHealth() == QLatin1String("no_forecast") ||
mgr.planHealth() == QLatin1String("degraded"),
"planHealth() must return a valid string");
// forceRecompute() again — must not crash with null registry
mgr.forceRecompute();
QVERIFY(true); // reached here = no crash
}
// ---------------------------------------------------------------------------
// Test D — LoadRole string serialization round-trip
// ---------------------------------------------------------------------------
void TestScheduler::testLoadRole_stringRoundTrip()
{
const QList<LoadRole> roles = {
LoadRole::EVCharger,
LoadRole::DHW,
LoadRole::HeatPump,
LoadRole::Battery,
LoadRole::SolarMeter,
LoadRole::GridMeter
};
foreach (LoadRole role, roles) {
const QString str = loadRoleToString(role);
const LoadRole restored = loadRoleFromString(str);
QVERIFY2(!str.isEmpty(), "loadRoleToString returned empty string");
QCOMPARE(restored, role);
}
}
// ---------------------------------------------------------------------------
// Test runner
// ---------------------------------------------------------------------------

View File

@ -32,11 +32,15 @@
#include "types/energytimeslot.h"
#include "types/flexibleload.h"
#include "types/loadrole.h"
#include "types/schedulerconfig.h"
#include "schedulingstrategies/rulebasedstrategy.h"
#include "schedulingstrategies/aistrategy.h"
#include "schedulingstrategies/manualstrategy.h"
#include "types/manualslotconfig.h"
#include "adapters/loadadapterregistry.h"
#include "adapters/adaptersettings.h"
#include "schedulermanager.h"
// Unit tests for the scheduling algorithm.
// These tests operate on pure algorithm logic (RuleBasedStrategy, AIStrategy)
@ -89,6 +93,18 @@ private slots:
// Test 9: ManualStrategy — JSON round-trip (persistence simulation)
void testManualStrategy_persistence();
// Test A: LoadAdapterRegistry::detectAdapterType — static method, no ThingManager needed
void testDetectAdapterType();
// Test B: AdapterSettings — save/load round-trip
void testAdapterSettings_roundTrip();
// Test C: SchedulerManager — null registry → applyCurrentSlot is a no-op (no crash)
void testSchedulerManager_nullRegistry_noCrash();
// Test D: LoadRole — string serialization round-trip
void testLoadRole_stringRoundTrip();
private:
// Build a synthetic 24h forecast starting at a given base time
QList<EnergyTimeSlot> buildWinterForecast(const QDateTime &start) const;