diff --git a/CLAUDE.md b/CLAUDE.md index b28f45e..7db8c07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,84 +1,133 @@ -# Agent Plugin — `powersync-energy-plugin-etm` +# Agent Plugin — `powersync-energy-plugin-etm` (GPL3) > Lire aussi le `CLAUDE.md` du dossier parent avant de commencer. --- ## Mon rôle -Je suis le **cœur du HEMS ETM-PowerSync**. Je contiens toute la logique -d'optimisation énergétique : gestion des consommateurs (EV, ECS, PAC), -tarification, météo, et décision d'activation par surplus solaire. +Je suis le **plugin nymea Community** du HEMS ETM-PowerSync. +Je contiens toute la logique GPL3 : recharge EV sur surplus, tarif HP/HC, +protection surcharge, et le pont vers `powersync-optimizer` pour les tiers payants. -Je suis un **code propriétaire ETM** — pas open-source, pas de publication upstream. +**Licence : GPL3** — tout mon code est open-source assumé. +Origine : fork de nymea-energy-plugin-nymea (source nymea/Chargebyte, GPL3). +Pas de remote upstream Git public — mises à jour via portage manuel depuis +`etm-nymea/nymea-energy-plugin-nymea`. + +--- + +## Règle fondamentale + +``` +Ce repo = fonctionnalités Community UNIQUEMENT. +Zéro logique Auto / Predict AI dans ce repo. +Ces features passent exclusivement par PowerSyncClient → optimizer. +``` --- ## Ce que je FOURNIS ### API JSON-RPC `EnergyPlugin.*` -| Méthode | Rôle | -|---|---| -| `GetChargingInfos(evChargerId)` | Config recharge EV (mode, deadline, SOC cible) | -| `SetChargingInfo(chargingInfo)` | Mettre à jour la config d'une borne EV | -| `GetChargingSchedules(evChargerId)` | Planning calculé par l'OptimizationEngine | -| `GetAvailableSpotMarketProviders()` | Liste des providers tarifs disponibles | -| `SetSpotMarketConfiguration(enabled, providerId)` | Activer/choisir un provider | -| `GetSpotMarketScoreEntries(date)` | Cotations horaires aWATTar | -| `SetPhasePowerLimit(Uint)` | Protection surcharge réseau (A/phase) | -| `SetAcquisitionTolerance(Double)` | Seuil surplus déclenchant la charge | -| `SetBatteryLevelConsideration(Double)` | Facteur batterie dans le calcul surplus | +| Méthode | Rôle | Tier | +|---|---|---| +| `GetChargingInfos(evChargerId)` | Config recharge EV | Community | +| `SetChargingInfo(chargingInfo)` | Mettre à jour config borne EV | Community | +| `GetChargingSchedules(evChargerId)` | Planning calculé | Community | +| `GetAvailableSpotMarketProviders()` | Liste providers tarifs | Community | +| `SetSpotMarketConfiguration(enabled, providerId)` | Activer provider | Community | +| `GetSpotMarketScoreEntries(date)` | Cotations horaires | Community | +| `SetPhasePowerLimit(Uint)` | Protection surcharge | Community | +| `SetAcquisitionTolerance(Double)` | Seuil surplus | Community | +| `SetBatteryLevelConsideration(Double)` | Facteur batterie | Community | ### Notifications push `ChargingInfoAdded/Removed/Changed`, `ChargingSchedulesChanged`, `SpotMarketConfigurationChanged`, `SpotMarketScoreEntriesChanged`, `PhasePowerLimitChanged` +### `.so` produit +``` +libnymea_energypluginnymea.so ← nom identique à l'upstream (drop-in replacement) +install : /usr/lib/nymea/energy/ +``` + --- ## Ce que je CONSOMME -### Interfaces nymea (détection par interface, jamais par ThingClassId) +### Interfaces nymea (par interface, jamais par ThingClassId) | Interface | États lus | Actions envoyées | |---|---|---| | `evcharger` | `chargingEnabled`, `maxChargingCurrent`, `pluggedIn`, `charging`, phases | `setChargingEnabled`, `setMaxChargingCurrent` | | `electricvehicle` | `batteryLevel`, `maxChargingCurrent`, `capacity` | — | | `rootmeter` / `energymeter` | `currentPowerPhaseA/B/C`, `currentPhaseA/B/C` | — | | `energystorage` | `currentPower`, `batteryLevel` | — | -| `thermostat` | `temperature`, `mode` | `setMode`, `setTargetTemperature` | -### Depuis `nymea-experience-plugin-energy` -- `EnergyManager*` injecté via `EnergyPlugin::init()` -- Signal `PowerBalanceEntryAdded` → déclenche le cycle d'optimisation (~1 min) +### Signal déclencheur +`PowerBalanceEntryAdded` depuis `nymea-experience-plugin-energy` → cycle ~1 min. + +### Service propriétaire (optionnel) +`PowerSyncClient` → Unix socket `/run/powersync/optimizer.sock` +Si absent → mode Community local, aucune erreur. --- -## Architecture interne cible +## Architecture interne ``` -powersync-energy-plugin-etm +powersync-energy-plugin-etm/ │ -├── TierManager ← lit /etc/powersync/tier.conf -│ └── active/désactive les modules selon le tier +├── [code upstream nymea/Chargebyte] ← ne pas modifier directement +│ ├── SmartChargingManager.* ← à corriger (bugs phase EV) +│ ├── SpotMarketManager.* ← aWATTar AT/DE ✅ +│ └── NymeaEnergyJsonHandler.* ← API JSON-RPC │ -├── OptimizationEngine ← chef d'orchestre (à créer) -│ ├── calcule le surplus PV disponible -│ ├── consulte TariffManager (tarif actuel) -│ ├── consulte WeatherManager (météo J+1 si tier Auto+) -│ └── distribue la puissance selon la priorité : -│ 1. ECS (priorité haute — chaleur) -│ 2. PAC (selon température extérieure) -│ 3. EV (selon deadline connue) -│ -├── ConsumerManager -│ ├── EvConsumer ← refactor SmartChargingManager existant -│ ├── EcsConsumer ← à créer -│ └── HeatPumpConsumer ← à créer -│ -├── TariffManager -│ ├── StaticHcHpProvider ← à créer (Community) -│ └── aWATTarProvider ← ✅ existe (AT + DE) -│ -└── WeatherManager - └── OpenMeteoProvider ← à créer (Auto uniquement) +└── etm/ ← tout notre code ETM ici + ├── PowerSyncClient.* ← pont vers optimizer (Unix socket) + ├── tariff/ + │ └── StaticHcHpProvider.* ← HP/HC statique (Community) + └── [futures extensions Community] +``` + +--- + +## PowerSyncClient — le pont vers l'optimizer + +```cpp +class PowerSyncClient : public QObject { + Q_OBJECT +public: + // Vérifie si powersync-optimizer tourne + bool isAvailable() const; + + // Demande une décision d'optimisation (Auto/Predict AI) + OptimizationResult requestOptimization(const SurplusData &data); + + // Récupère la météo J+1 (Auto) + WeatherForecast getWeatherForecast(); + + // Récupère le tarif dynamique courant (Predict AI) + TariffData getDynamicTariff(); + +signals: + void availabilityChanged(bool available); + void optimizationResultReceived(OptimizationResult result); +}; +``` + +**Comportement du cycle principal :** +```cpp +void SmartChargingManager::runCycle() { + if (m_powerSyncClient->isAvailable()) { + // Auto / Predict AI — délègue à l'optimizer + auto result = m_powerSyncClient->requestOptimization(buildSurplusData()); + applyOptimizationResult(result); + } else { + // Community — logique GPL3 locale + planSurplusCharging(); // EV sur surplus + planSpotMarketCharging(); // EV sur aWATTar + } +} ``` --- @@ -86,36 +135,39 @@ powersync-energy-plugin-etm ## État actuel du code ### ✅ Fonctionnel -- `SmartChargingManager` : recharge EV sur surplus solaire (mode Eco) -- `SpotMarketManager` : planification aWATTar AT/DE avec cache 24h -- `NymeaEnergyJsonHandler` : API JSON-RPC `EnergyPlugin.*` complète +- SmartChargingManager : recharge EV surplus (mode Eco) + aWATTar AT/DE - Overload protection triphasée -- Détection appareils par interface (zero UUID hardcodé) +- API JSON-RPC `EnergyPlugin.*` complète +- Détection appareils par interface (zéro UUID hardcodé) ### ❌ À corriger en priorité | Fichier | Problème | Priorité | |---|---|---| -| `evcharger.cpp:171`, `smartchargingmanager.cpp:394,477,517` | Assume toujours phase A — faux pour EV monophasé sur B/C | 🔴 Haute | -| `smartchargingmanager.cpp:59` | Migration `endTime → endDateTime` + récurrence hebdo non terminée | 🟠 Moyenne | -| `smartchargingmanager.cpp:884` | Planification limitée à 24h | 🟠 Moyenne | -| `smartchargingmanager.cpp:1835` | Actions EV non séquentielles, pas de retry | 🟠 Moyenne | -| `EnergyPluginNymea::init()` | Pas de guard si `EnergyManager*` est null | 🟠 Moyenne | +| `evcharger.cpp:171`, `smartchargingmanager.cpp:394,477,517` | Assume toujours phase A — faux pour EV sur phase B/C | 🔴 | +| `EnergyPluginNymea::init()` | Pas de guard si `EnergyManager*` null | 🟠 | +| `smartchargingmanager.cpp:59` | Récurrence hebdo non terminée | 🟠 | +| `smartchargingmanager.cpp:884` | Planification limitée à 24h | 🟠 | +| `smartchargingmanager.cpp:1835` | Actions EV non séquentielles, pas de retry | 🟠 | -### ❌ À créer -- `StaticHcHpProvider` (TariffManager — Community) -- `EcsConsumer` (chauffe-eau / ECS sur surplus) -- `HeatPumpConsumer` (PAC sur surplus) -- `OptimizationEngine` (orchestrateur multi-consommateurs) -- `WeatherManager` + `OpenMeteoProvider` (Auto) -- `TierManager` + lecture `/etc/powersync/tier.conf` +### ❌ À créer (code ETM dans `etm/`) +- `PowerSyncClient` (pont Unix socket vers optimizer) +- `StaticHcHpProvider` (tarif HP/HC statique — Community) --- ## Règles de modification -- Tout changement de signature `EnergyPlugin.*` → mettre à jour `INTERFACE.md` -- Tout nouveau StateType ou ActionType → notifier l'Agent App -- Ne jamais modifier `nymea-experience-plugin-energy` depuis ce repo -- Tester sur un système Community avant d'activer des features Auto/Predict AI +- Tout code ETM va dans `etm/` — jamais dans le code upstream +- Modifier le code upstream uniquement pour corriger des bugs (FIXME existants) +- Tout changement d'API `EnergyPlugin.*` → mettre à jour `INTERFACE.md` +- Ne jamais ajouter de logique Auto/Predict AI dans ce repo - Build : `qmake energyplugin.pro && make -j$(nproc)` -- Install : `/usr/lib/nymea/energy/libnymea_energypluginnymea.so` + +--- + +## Portage des mises à jour nymea/Chargebyte + +Quand une nouvelle version est disponible dans `etm-nymea/nymea-energy-plugin-nymea` : +1. `diff -r etm-nymea/nymea-energy-plugin-nymea/ powersync-energy-plugin-etm/` +2. Porter manuellement les corrections hors dossier `etm/` +3. Ne jamais écraser `etm/` diff --git a/energyplugin/evcharger.cpp b/energyplugin/evcharger.cpp index 5329e18..f5c0649 100644 --- a/energyplugin/evcharger.cpp +++ b/energyplugin/evcharger.cpp @@ -168,13 +168,13 @@ Electricity::Phases EvCharger::phases() const } } - // TODO: Can we figure out from the root meter on which phase we're attached? - // One idea would be to sign up on root meter changes. When chargingEnabled is set or unset, - // memorize root meter values and compare the next (or some more) cycles if a phase changed - // by a similar value as we'd expect - - // Until we have this detection, we must ask the user how many phases will be used while charging + // For single-phase chargers, detect the actual connected phase from per-phase metering + Electricity::Phases metered = meteredPhases(); + if (metered != Electricity::PhaseNone) { + return metered; + } + // Phase unknown — charger has no per-phase metering or is not currently charging return Electricity::PhaseNone; } diff --git a/energyplugin/smartchargingmanager.cpp b/energyplugin/smartchargingmanager.cpp index 20fccc4..14dbdb7 100644 --- a/energyplugin/smartchargingmanager.cpp +++ b/energyplugin/smartchargingmanager.cpp @@ -391,9 +391,9 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate if (evCharger->phaseCount() == 1) { - // FIXME: get the actual phase, not assume it is phase A in single phase charging - if (requiredThrottlePower.value("A") > 0) { - int throttleAmpere = qCeil(requiredThrottlePower.value("A") / 230); + const QString phase = chargerPhaseKey(evCharger); + if (requiredThrottlePower.value(phase) > 0) { + int throttleAmpere = qCeil(requiredThrottlePower.value(phase) / 230); int desiredFallbackAmpere = evCharger->maxChargingCurrent() - throttleAmpere; if (desiredFallbackAmpere < static_cast(m_processInfos[evCharger].minimalChargingCurrent)) { @@ -474,8 +474,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur bool restoreCharger = false; if (evCharger->phaseCount() == 1) { - // FIXME: get the actual phase, not assume it is phase A in single phase charging - if (availablePhasePower.value("A") >= requiredRestorePhasePower) { + if (availablePhasePower.value(chargerPhaseKey(evCharger)) >= requiredRestorePhasePower) { qCDebug(dcNymeaEnergy()) << "Overload protection: Enought power available to restore the original configuration" << manualMaxChargingCurrent(evCharger->id()) << "[A]"; restoreCharger = true; @@ -514,8 +513,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur bool restoreChargerPower = false; if (evCharger->phaseCount() == 1) { - // FIXME: get the actual phase, not assume it is phase A in single phase charging - if (availablePhasePower.value("A") >= requiredRestorePhasePower) { + if (availablePhasePower.value(chargerPhaseKey(evCharger)) >= requiredRestorePhasePower) { qCDebug(dcNymeaEnergy()) << "Overload protection: Enought power available to start charging using the minimal charging current of" << m_processInfos.value(evCharger).minimalChargingCurrent << "[A]"; restoreChargerPower = true; @@ -1770,6 +1768,28 @@ Electricity::Phases SmartChargingManager::getAscendingPhasesForCount(uint phaseC return phases; } +QString SmartChargingManager::chargerPhaseKey(EvCharger *evCharger) const +{ + // Use live metering first — reliable when charger is actively charging + Electricity::Phases metered = evCharger->meteredPhases(); + if (metered.testFlag(Electricity::PhaseA)) return QStringLiteral("A"); + if (metered.testFlag(Electricity::PhaseB)) return QStringLiteral("B"); + if (metered.testFlag(Electricity::PhaseC)) return QStringLiteral("C"); + + // Charger may be off — fall back to last known phase stored by prepareInformation() + if (m_processInfos.contains(evCharger)) { + Electricity::Phases known = m_processInfos.value(evCharger).effectivePhases; + if (known.testFlag(Electricity::PhaseA)) return QStringLiteral("A"); + if (known.testFlag(Electricity::PhaseB)) return QStringLiteral("B"); + if (known.testFlag(Electricity::PhaseC)) return QStringLiteral("C"); + } + + // Phase unknown — conservative fallback (unchanged previous behavior) + qCWarning(dcNymeaEnergy()) << "Cannot determine connected phase for" + << evCharger->name() << "— defaulting to phase A"; + return QStringLiteral("A"); +} + uint SmartChargingManager::getBestPhaseCount(EvCharger *evCharger, double surplusAmpere) { uint desiredPhaseCount = 1; diff --git a/energyplugin/smartchargingmanager.h b/energyplugin/smartchargingmanager.h index 833bcaa..1099c34 100644 --- a/energyplugin/smartchargingmanager.h +++ b/energyplugin/smartchargingmanager.h @@ -123,6 +123,7 @@ private: Electricity::Phases getAscendingPhasesForCount(uint phaseCount); uint getBestPhaseCount(EvCharger *evCharger, double surplusAmpere); + QString chargerPhaseKey(EvCharger *evCharger) const; EnergyManager *m_energyManager = nullptr; ThingManager *m_thingManager = nullptr;