From 67836b72348418ba4662d7d0b1652a6725438018 Mon Sep 17 00:00:00 2001 From: pakutz79 Date: Tue, 24 Feb 2026 09:15:06 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Installer=20Setup=20=E2=80=94=20LoadAda?= =?UTF-8?q?pterRegistry=20+=20adapter=20layer=20(v12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- doc.md | 170 ++++++++ energyplugin/adapters/adaptersettings.cpp | 126 ++++++ energyplugin/adapters/adaptersettings.h | 84 ++++ energyplugin/adapters/batteryadapter.cpp | 160 +++++++ energyplugin/adapters/batteryadapter.h | 65 +++ energyplugin/adapters/evchargeradapter.cpp | 168 ++++++++ energyplugin/adapters/evchargeradapter.h | 66 +++ energyplugin/adapters/iloadapter.h | 86 ++++ energyplugin/adapters/loadadapterregistry.cpp | 408 ++++++++++++++++++ energyplugin/adapters/loadadapterregistry.h | 151 +++++++ energyplugin/adapters/relayadapter.cpp | 151 +++++++ energyplugin/adapters/relayadapter.h | 64 +++ energyplugin/adapters/sgreadyadapter.cpp | 176 ++++++++ energyplugin/adapters/sgreadyadapter.h | 75 ++++ energyplugin/energyplugin.pri | 14 + energyplugin/energypluginnymea.cpp | 24 +- energyplugin/nymeaenergyjsonhandler.cpp | 236 +++++++++- energyplugin/nymeaenergyjsonhandler.h | 21 + energyplugin/schedulermanager.cpp | 35 +- energyplugin/schedulermanager.h | 11 +- energyplugin/types/loadrole.h | 109 +++++ tests/auto/scheduler/testscheduler.cpp | 124 ++++++ tests/auto/scheduler/testscheduler.h | 16 + 23 files changed, 2524 insertions(+), 16 deletions(-) create mode 100644 energyplugin/adapters/adaptersettings.cpp create mode 100644 energyplugin/adapters/adaptersettings.h create mode 100644 energyplugin/adapters/batteryadapter.cpp create mode 100644 energyplugin/adapters/batteryadapter.h create mode 100644 energyplugin/adapters/evchargeradapter.cpp create mode 100644 energyplugin/adapters/evchargeradapter.h create mode 100644 energyplugin/adapters/iloadapter.h create mode 100644 energyplugin/adapters/loadadapterregistry.cpp create mode 100644 energyplugin/adapters/loadadapterregistry.h create mode 100644 energyplugin/adapters/relayadapter.cpp create mode 100644 energyplugin/adapters/relayadapter.h create mode 100644 energyplugin/adapters/sgreadyadapter.cpp create mode 100644 energyplugin/adapters/sgreadyadapter.h create mode 100644 energyplugin/types/loadrole.h diff --git a/doc.md b/doc.md index 4e20b68..97eb741 100644 --- a/doc.md +++ b/doc.md @@ -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: "` +- `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* diff --git a/energyplugin/adapters/adaptersettings.cpp b/energyplugin/adapters/adaptersettings.cpp new file mode 100644 index 0000000..93dc89e --- /dev/null +++ b/energyplugin/adapters/adaptersettings.cpp @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "adaptersettings.h" + +#include + +#include +#include + +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 AdapterSettings::assignments() const +{ + return m_assignments; +} + +void AdapterSettings::save(const QList &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 ¶ms) +{ + 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(); +} diff --git a/energyplugin/adapters/adaptersettings.h b/energyplugin/adapters/adaptersettings.h new file mode 100644 index 0000000..63d33f3 --- /dev/null +++ b/energyplugin/adapters/adaptersettings.h @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef ADAPTERSETTINGS_H +#define ADAPTERSETTINGS_H + +#include +#include +#include + +#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 = +// adapterType = relay +// nominalPowerW = 2000 +// +// [roles/2] +// role = HeatPump +// enabled = true +// thingId = +// adapterType = sgready +// relay1ThingId = +// relay2ThingId = +// 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 assignments() const; + + // Persist the given set of raw assignment maps (from LoadAdapterRegistry::rawAssignments()). + void save(const QList &assignments); + + // Override a single role's assignment (loads first, then upserts). + void setAssignment(LoadRole role, const QVariantMap ¶ms); + + // 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 m_assignments; +}; + +#endif // ADAPTERSETTINGS_H diff --git a/energyplugin/adapters/batteryadapter.cpp b/energyplugin/adapters/batteryadapter.cpp new file mode 100644 index 0000000..16cf56d --- /dev/null +++ b/energyplugin/adapters/batteryadapter.cpp @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "batteryadapter.h" + +#include "types/action.h" +#include "integrations/thingmanager.h" +#include "integrations/thingactioninfo.h" + +#include + +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; +} diff --git a/energyplugin/adapters/batteryadapter.h b/energyplugin/adapters/batteryadapter.h new file mode 100644 index 0000000..eac2075 --- /dev/null +++ b/energyplugin/adapters/batteryadapter.h @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef BATTERYADAPTER_H +#define BATTERYADAPTER_H + +#include "adapters/iloadapter.h" + +#include + +// 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 diff --git a/energyplugin/adapters/evchargeradapter.cpp b/energyplugin/adapters/evchargeradapter.cpp new file mode 100644 index 0000000..00fdca5 --- /dev/null +++ b/energyplugin/adapters/evchargeradapter.cpp @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "evchargeradapter.h" + +#include "types/action.h" +#include "integrations/thingmanager.h" +#include "integrations/thingactioninfo.h" + +#include +#include + +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(qBound(static_cast(m_minA), amps, static_cast(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; +} diff --git a/energyplugin/adapters/evchargeradapter.h b/energyplugin/adapters/evchargeradapter.h new file mode 100644 index 0000000..12dfebf --- /dev/null +++ b/energyplugin/adapters/evchargeradapter.h @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef EVCHARGERADAPTER_H +#define EVCHARGERADAPTER_H + +#include "adapters/iloadapter.h" + +#include + +// 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 diff --git a/energyplugin/adapters/iloadapter.h b/energyplugin/adapters/iloadapter.h new file mode 100644 index 0000000..4aea7dd --- /dev/null +++ b/energyplugin/adapters/iloadapter.h @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef ILOADAPTER_H +#define ILOADAPTER_H + +#include +#include + +#include + +#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 diff --git a/energyplugin/adapters/loadadapterregistry.cpp b/energyplugin/adapters/loadadapterregistry.cpp new file mode 100644 index 0000000..5b3d79a --- /dev/null +++ b/energyplugin/adapters/loadadapterregistry.cpp @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "loadadapterregistry.h" +#include "adaptersettings.h" +#include "adapters/relayadapter.h" +#include "adapters/sgreadyadapter.h" +#include "adapters/evchargeradapter.h" +#include "adapters/batteryadapter.h" + +#include + +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 LoadAdapterRegistry::compatibleThings(LoadRole role) const +{ + QList 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 ¶ms) +{ + 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 LoadAdapterRegistry::rawAssignments() const +{ + QList 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 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 ¶ms) +{ + 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; +} diff --git a/energyplugin/adapters/loadadapterregistry.h b/energyplugin/adapters/loadadapterregistry.h new file mode 100644 index 0000000..aef8231 --- /dev/null +++ b/energyplugin/adapters/loadadapterregistry.h @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef LOADADAPTERREGISTRY_H +#define LOADADAPTERREGISTRY_H + +#include +#include +#include +#include +#include + +#include + +#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 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 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 ¶ms); + + // 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 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 ¶ms); + RoleStatus buildRoleStatus(LoadRole role) const; + + ThingManager *m_thingManager; + QHash m_entries; +}; + +#endif // LOADADAPTERREGISTRY_H diff --git a/energyplugin/adapters/relayadapter.cpp b/energyplugin/adapters/relayadapter.cpp new file mode 100644 index 0000000..8ddd30d --- /dev/null +++ b/energyplugin/adapters/relayadapter.cpp @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "relayadapter.h" + +#include "types/action.h" +#include "integrations/thingmanager.h" +#include "integrations/thingactioninfo.h" + +#include +#include + +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(); + }); +} diff --git a/energyplugin/adapters/relayadapter.h b/energyplugin/adapters/relayadapter.h new file mode 100644 index 0000000..c49a0a3 --- /dev/null +++ b/energyplugin/adapters/relayadapter.h @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef RELAYADAPTER_H +#define RELAYADAPTER_H + +#include "adapters/iloadapter.h" + +#include + +// 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 diff --git a/energyplugin/adapters/sgreadyadapter.cpp b/energyplugin/adapters/sgreadyadapter.cpp new file mode 100644 index 0000000..e686af1 --- /dev/null +++ b/energyplugin/adapters/sgreadyadapter.cpp @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "sgreadyadapter.h" + +#include "types/action.h" +#include "integrations/thingmanager.h" +#include "integrations/thingactioninfo.h" + +#include +#include + +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(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); +} diff --git a/energyplugin/adapters/sgreadyadapter.h b/energyplugin/adapters/sgreadyadapter.h new file mode 100644 index 0000000..b31a735 --- /dev/null +++ b/energyplugin/adapters/sgreadyadapter.h @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef SGREADYADAPTER_H +#define SGREADYADAPTER_H + +#include "adapters/iloadapter.h" + +#include + +// 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) → 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 diff --git a/energyplugin/energyplugin.pri b/energyplugin/energyplugin.pri index 0da5ae0..0bae1de 100644 --- a/energyplugin/energyplugin.pri +++ b/energyplugin/energyplugin.pri @@ -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 \ diff --git a/energyplugin/energypluginnymea.cpp b/energyplugin/energypluginnymea.cpp index 2e76ae3..7f44edb 100644 --- a/energyplugin/energypluginnymea.cpp +++ b/energyplugin/energypluginnymea.cpp @@ -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); } diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 3defa7f..7027c67 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -28,6 +28,8 @@ #include "spotmarket/spotmarketmanager.h" #include "schedulermanager.h" #include "schedulingstrategies/manualstrategy.h" +#include "adapters/loadadapterregistry.h" +#include "types/loadrole.h" #include @@ -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(); @@ -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()); + 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()); + 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()); + 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()); + 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 QListsetupStatus())}}); +} + +JsonReply *NymeaEnergyJsonHandler::GetCompatibleThings(const QVariantMap ¶ms) +{ + 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 ¶ms) +{ + 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 ¶ms) +{ + 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 ¶ms) +{ + 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 ¶ms) +{ + 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; +} diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 26eda6b..a98a0f3 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -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 ¶ms); Q_INVOKABLE JsonReply *ClearManualSlots(const QVariantMap ¶ms); + // --- Installer Setup API (NymeaEnergy v12) --- + Q_INVOKABLE JsonReply *GetSetupStatus(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *GetCompatibleThings(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *AssignThingToRole(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *UnassignRole(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *SetRoleEnabled(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *TestRoleConnection(const QVariantMap ¶ms); + signals: void PhasePowerLimitChanged(const QVariantMap ¶ms); void AcquisitionToleranceChanged(const QVariantMap ¶ms); @@ -109,10 +119,16 @@ signals: // Manual slot push notification void ManualSlotActivated(const QVariantMap ¶ms); + // Installer setup push notifications (v12) + void SetupStatusChanged(const QVariantMap ¶ms); + void ConnectionTestResult(const QVariantMap ¶ms); + void ThingBecameCompatible(const QVariantMap ¶ms); + private: SpotMarketManager *m_spotMarketManager; SmartChargingManager *m_smartChargingManager = nullptr; SchedulerManager *m_schedulerManager = nullptr; + LoadAdapterRegistry *m_adapterRegistry = nullptr; void sendSpotMarketConfigurationChangedNotification(); QVariantMap buildTimelineSummary(const QList &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 diff --git a/energyplugin/schedulermanager.cpp b/energyplugin/schedulermanager.cpp index 2d811c8..ed0e61b 100644 --- a/energyplugin/schedulermanager.cpp +++ b/energyplugin/schedulermanager.cpp @@ -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 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 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 diff --git a/energyplugin/schedulermanager.h b/energyplugin/schedulermanager.h index c431c99..aafe3f3 100644 --- a/energyplugin/schedulermanager.h +++ b/energyplugin/schedulermanager.h @@ -42,6 +42,7 @@ #include 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 m_strategies; diff --git a/energyplugin/types/loadrole.h b/energyplugin/types/loadrole.h new file mode 100644 index 0000000..834ef88 --- /dev/null +++ b/energyplugin/types/loadrole.h @@ -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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef LOADROLE_H +#define LOADROLE_H + +#include +#include +#include + +#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 loadRoleCompatibleInterfaces() +{ + QMap 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 allLoadRoles() +{ + return { + LoadRole::EVCharger, + LoadRole::DHW, + LoadRole::HeatPump, + LoadRole::Battery, + LoadRole::SolarMeter, + LoadRole::GridMeter + }; +} + +#endif // LOADROLE_H diff --git a/tests/auto/scheduler/testscheduler.cpp b/tests/auto/scheduler/testscheduler.cpp index cb43552..d799cca 100644 --- a/tests/auto/scheduler/testscheduler.cpp +++ b/tests/auto/scheduler/testscheduler.cpp @@ -24,6 +24,9 @@ #include "testscheduler.h" #include +#include +#include +#include #include // Typical winter day electricity prices (EUR/kWh), hours 00:00–23: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 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 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 // --------------------------------------------------------------------------- diff --git a/tests/auto/scheduler/testscheduler.h b/tests/auto/scheduler/testscheduler.h index 4b36466..12e1aad 100644 --- a/tests/auto/scheduler/testscheduler.h +++ b/tests/auto/scheduler/testscheduler.h @@ -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 buildWinterForecast(const QDateTime &start) const;