docs: replace upstream nymea README with ETM PowerSync README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-06 09:34:51 +02:00
parent b8e882616b
commit a751a4ccb6

245
README.md
View File

@ -1,74 +1,237 @@
# etm-powersync-energy-etm
# nymea energy manager
> Moteur HEMS pour l'expérience énergie de [nymea](https://nymea.io).
> Fork de `nymea-energy-plugin-nymea` (GPL-3.0), étendu d'un optimiseur EV vers un gestionnaire d'énergie complet : bornes, ECS, PAC, batterie, relais — pilotés sous une seule logique d'arbitrage, en local, sans dépendance cloud.
Repository for the energy plugin for the energy experience.
Ce plugin est un `EnergyPlugin` (il hérite de `libnymea-energy`, ce n'est **pas** un `IntegrationPlugin`). Il n'expose aucune `ThingClass` propre : il orchestre les Things créées par d'autres plugins, détectées **par interface** (`evcharger`, `electricvehicle`, `rootmeter`/`energymeter`, `energystorage`, et à terme `heatingelement`, `heatpump`, relais).
This energy plugin adds smart charging, spotmarket charging and overload protection to the nymea energy experience.
Produit : `libnymea_energypluginetm.so``/usr/lib/nymea/energy/`
## Energy manager configuration
---
In order to tweak local energy manager setups a read only configuration file has been introduced. The file can be specified using the environment variable
`NYMEA_ENERGY_MANAGER_CONFIG`. If the environment path has not been specified, by default the energy manager will try to load the file from `/var/lib/nymea/energy-manager-configuration.json`.
## Statuts honnêtes
If there is no such file, the defaults will be used, which is **recommended**.
Chaque bloc affiche son état réel. On ne documente pas une promesse.
> Note: this configuration is desinged to test certain behaviors in certain test environments, it is not designed to be used by default or changed by the user.
| Statut | Sens |
|---|---|
| **STABLE** | Hérité de l'amont, fonctionne en production |
| **EN COURS** | En industrialisation pour la beta |
| **ROADMAP** | Planifié, non implémenté |
The default configuration looks like this:
---
## Modèle open-cœur
Deux couches, frontière nette. Le plugin de ce repo est **entièrement GPL-3.0**.
- **Couche A — ce repo (GPL-3.0).** Toute la mécanique HEMS : ingestion d'état, sécurité, arbitrage, stratégie par défaut basée sur règles, adaptateurs de charge. Tourne seul, sans aucun composant externe. C'est aussi le candidat à une contribution amont chez nymea.
- **Couche B — optimiseur externe (hors de ce repo).** Un processus séparé (propriétaire **Héos**, ou tiers communautaire) qui se branche par un socket local documenté. Il *propose* des décisions ; il ne peut jamais contourner la sécurité.
Principe directeur : **le plugin est agnostique à l'optimiseur.** Héos et un optimiseur tiers sont des backends interchangeables derrière le même contrat. Aucun code propriétaire ne vit dans ce repo — la séparation se fait par processus + protocole publié, pas par linkage.
Conséquence pratique, et invariant testé : **socket absent ou muet → repli automatique sur la stratégie de règles intégrée.** En mode Community, le plugin fonctionne intégralement sans optimiseur.
---
## Architecture — les blocs
### 1. Ingestion d'état — STABLE
Le plugin lit l'état du système via les interfaces nymea, jamais par `ThingClassId`.
| Interface | États lus | Actions émises |
|---|---|---|
| `evcharger` | `chargingEnabled`, `maxChargingCurrent`, `pluggedIn`, `charging`, `currentPhaseA/B/C`, `currentPowerPhaseA/B/C` | `setChargingEnabled`, `setMaxChargingCurrent` |
| `electricvehicle` | `batteryLevel`, `maxChargingCurrent`, `capacity`, `minChargingCurrent`, `phaseCount` | — |
| `rootmeter` / `energymeter` | `currentPowerPhaseA/B/C`, `currentPhaseA/B/C` | — |
| `energystorage` | `currentPower`, `batteryLevel` | — |
| `heatingelement`, `heatpump`, relais | à définir | à définir — ROADMAP |
Le `currentPower` du root meter est **négatif en cas de surplus** (export réseau). C'est la grandeur d'entrée centrale du moteur.
Deux déclencheurs distincts alimentent les deux boucles (voir §2) :
- `PowerBalanceEntryAdded` (`SampleRate1Min`) → le **cycle d'optimisation** `update()`, ~1×/min.
- `powerBalanceChanged` (temps réel, à chaque changement) → la **boucle de sécurité**, découplée du cycle.
### 2. Les deux boucles — STABLE
C'est la décision d'architecture la plus importante du moteur : **sécurité et optimisation sont deux boucles séparées, à cadences différentes, avec des priorités différentes.**
- **Boucle de sécurité** (`verifyOverloadProtection()`) tourne en temps réel sur `powerBalanceChanged`. Elle lit le courant par phase du root meter ; si une phase dépasse `phasePowerLimit × 230 W`, elle throttle immédiatement (issuer `OverloadProtection`). `verifyOverloadProtectionRecovery()` ré-active quand la marge revient.
- **Boucle d'optimisation** (`update()`) tourne ~1×/min sur le cycle lent. C'est elle, et elle seule, que l'optimiseur externe peut influencer.
L'optimiseur **ne touche jamais** la boucle de sécurité. Quoi qu'il propose, le plafond de puissance (overload, et à terme la contrainte réseau §14a) borne la sortie. C'est ce qui rend l'optimiseur débranchable, et autorise du code tiers non audité à se brancher sans risque pour l'installation.
### 3. Arbitrage central — EN COURS
L'amont calcule un budget de surplus pour les bornes EV uniquement, via le terme `addedPower` de `planSurplusCharging()` (« puissance déjà engagée dans ce cycle »). Le fork **généralise ce budget en allocation centrale** : un coordinateur unique tient le budget de surplus disponible, interroge chaque charge **par priorité décroissante**, et décrémente le budget à chaque engagement.
C'est la pièce qui évite le sur-engagement : sans budget central, un manager ECS, un manager batterie et le manager EV verraient chacun « 2 kW de surplus » et tireraient 6 kW. L'allocation reste **unique et centrale** ; les charges ne sont que des consommatrices du budget.
### 4. Adaptateurs de charge — EN COURS
L'application d'une décision à un appareil passe par un adaptateur par type de charge. L'amont a `executeChargingAction()``Thing::executeAction(setChargingEnabled, setMaxChargingCurrent)` pour l'EV. Le fork généralise : un `LoadAdapter` traduit une **allocation de puissance** en action device selon le type — ampères pour la borne, palier de relais pour l'ECS, décalage de consigne pour la PAC.
L'EV devient ainsi un type de charge parmi d'autres, et non plus le centre du moteur.
| Adaptateur | Statut |
|---|---|
| `evcharger` (hérité) | STABLE |
| ECS / relais | EN COURS (cible beta) |
| PAC / HVAC | ROADMAP |
| Batterie | ROADMAP |
### 5. Stratégie par défaut basée sur règles — EN COURS
La logique de décision locale (`planSurplusCharging()`, `planSpotMarketCharging()`) devient la **stratégie par défaut**, intégrée au plugin, GPL, sans aucune dépendance externe. C'est elle qui pilote en mode Community et qui prend le relais quand l'optimiseur est absent. Glouton par priorité statique avec décrément de budget — simple, déterministe, vérifiable.
### 6. Frontière A↔B — `requestOptimization()` — EN COURS
Point d'injection unique entre la couche GPL et l'optimiseur externe, **entre `prepareInformation()` et `adjustEvChargers()`** dans le cycle. Le plugin construit un contexte (`SurplusData`/`SurplusContext`) et appelle `PowerSyncClient::requestOptimization()` ; l'optimiseur retourne une allocation (`ChargingAction` aujourd'hui, `LoadAction` généralisé en cible) qui remplace ou complète les décisions locales.
Ce contrat sert trois usages d'un seul artefact : interface de stratégie interne, protocole du socket (Couche B), et spécification publiée pour la communauté.
---
## Comment ça fonctionne ensemble — le cycle `update()`
Exécuté à chaque entrée du bilan de puissance (~1 min). Pipeline hérité de l'amont, généralisé par le fork :
```
update(currentDateTime)
├─ 1. updateManualSoCsWithoutMeter() estime le SoC si pas de compteur sur le VE
├─ 2. prepareInformation() filtre les charges actives, calcule phases/SoC/temps restant,
│ reset des actions par issuer
│ ┌───────────────────────────────────────────────┐
│ ── FRONTIÈRE A↔B ──────────┤ requestOptimization(SurplusContext) │
│ │ optimiseur vivant → LoadAction[] │
│ │ absent / muet / abstain → stratégie défaut │
│ └───────────────────────────────────────────────┘
├─ 3. verifyOverloadProtection() SÉCURITÉ — borne la sortie, quoi qu'ait proposé l'optimiseur
├─ 4. verifyOverloadProtectionRecovery()
├─ 5. planSpotMarketCharging() créneaux bon marché (SpotMarketManager) → schedules
├─ 6. planSurplusCharging() currentLoad = rootMeter.currentPower()
│ + correction batteries + addedPower (budget)
│ allowanceInAmpere = currentLoad / 230
└─ 7. adjustEvChargers() / applyAllocation()
décision finale par priorité :
1. TimeRequirement (deadline imminente)
2. SurplusCharging (courant surplus)
3. SpotMarketCharging (créneau bon marché)
4. fallback courant minimum (6 A)
5. Idle (OFF)
├─ executeChargingAction() → Thing::executeAction(...)
├─ emit chargingInfoChanged() (ChargingState lu en JSON-RPC)
└─ emit chargingSchedulesChanged() (planning lu en JSON-RPC)
```
En parallèle, hors de ce cycle, la boucle de sécurité tourne en temps réel sur `powerBalanceChanged` et peut throttler à tout instant, indépendamment de l'optimiseur.
---
## Couche B — brancher un optimiseur sur le socket
L'optimiseur est un **serveur** ; le plugin est le **client**. L'optimiseur ouvre `/run/powersync/optimizer.sock`, le plugin s'y connecte.
Cycle de vie d'un backend (Héos ou tiers) :
1. **Handshake** — le backend annonce `{ protocolVersion, name }`. Le plugin vérifie la version ; incompatible → refus + repli règles.
2. **Boucle** — à chaque cycle le plugin pousse un `SurplusContext` (surplus disponible, charges avec type/puissance/min-max/priorité/état, batterie SoC, tarif, contrainte réseau) ; le backend renvoie un `LoadAction[]` (`{ loadId, action, targetPowerW, reason }`) ou `abstain`.
3. **Heartbeat** — la cadence des requêtes *est* le heartbeat. Pas de réponse dans le délai → `optimizerAlive=false` → repli règles, sans interruption du pilotage.
Le contrat est **générique-domaine**, pas spécifique à Héos : n'importe qui peut écrire un serveur (Python+ML, Rust, etc.) qui répond au même protocole. La spécification est publiée sous licence libre — voir `docs/OPTIMIZER_PROTOCOL.md` (ROADMAP).
> Sécurité : quelle que soit la sortie du backend, la boucle de sécurité et les bornes min/max par charge **plafonnent** l'allocation. Un optimiseur défaillant ne peut, au pire, que proposer une mauvaise répartition — bornée et clampée.
---
## Interface JSON-RPC
Namespace `NymeaEnergy` (hérité du fork ; migration vers un namespace EMS générique à l'étude pour l'amont). Transport WebSocket JSON-RPC 2.0, port nymea standard 4444. Voir [`INTERFACE.md`](INTERFACE.md) pour la référence complète (méthodes, types, notifications).
Surfaces principales : overload (`Get/SetPhasePowerLimit`), paramètres de charge (`acquisitionTolerance`, `batteryLevelConsideration`, `lockOnUnplug`), configuration EV (`Get/SetChargingInfo`), spot market.
---
## Configuration
Deux étages, à ne pas confondre :
**Tuning intégrateur — `EnergyManagerConfiguration`** (read-only, chargé une seule fois au démarrage ; un changement exige un redémarrage du daemon). Chemin : `$NYMEA_ENERGY_MANAGER_CONFIG`, sinon `/var/lib/nymea/energy-manager-configuration.json`, sinon les défauts (recommandé).
```json
{
"chargingEnabledLockDuration": 300,
"chargingCurrentLockDuration": 5,
"minimumScheduleDuration": 15,
"spotMarketChargePredictableEnergyPercentage": 0.5
"chargingEnabledLockDuration": 300,
"chargingCurrentLockDuration": 5,
"minimumScheduleDuration": 15,
"spotMarketChargePredictableEnergyPercentage": 0.5
}
```
* `chargingEnabledLockDuration`: defines in seconds how long a charger is locked when charging has been paused or resumed. Default is 5 minutes, which ensures a certain continuity for the car. Thsi prevents a constant charging start and stop.
* `chargingCurrentLockDuration`: defines in seconds how long a charger is locked when the charging current has been changed. According to the spec this can be changed every 10 seconds. For tests we can go under this spec limit.
* `minimumScheduleDuration`: duration in minutes. When spotmarket charging scheduled get calculated, there might be a situation where charging wpuld stop for an hour and the rest would be only one minute. In order to prevent such small schedules, this limit will be considered from the scheduler, the minimum is 15 minutes, everything underneeth will be appende or prepended to existing charging slots.
* `spotMarketChargePredictableEnergyPercentage`: if the spotmarket data are not availabe yet for the next day, this paramter specifies how much will be planed with the already known schedule. The rest will be planed once the schedules are available.
| Paramètre | Défaut | Unité | Rôle |
|---|---|---|---|
| `chargingEnabledLockDuration` | 300 | s | Anti-flapping après ON/OFF |
| `chargingCurrentLockDuration` | 5 | s | Anti-flapping après changement de courant |
| `minimumScheduleDuration` | 15 | min | Durée minimale d'un créneau spot market |
| `spotMarketChargePredictableEnergyPercentage` | 0.5 | ratio | Fraction d'énergie spot considérée prédictible |
> Cette configuration sert à tester des comportements en environnement de test. Elle n'est pas destinée à être modifiée par l'utilisateur.
## Simulations
**Settings utilisateur** (runtime, via JSON-RPC, persistés dans `EnergySettings` / QSettings INI `energy.conf`) : `phasePowerLimit`, `acquisitionTolerance`, `batteryLevelConsideration`, les `ChargingInfo` par charger, l'état spot market. `lockOnUnplug` vit dans un QSettings séparé. Les seuils utilisateur ajoutés par le fork (seuil surplus par charge, priorités) suivent ce **chemin runtime**, jamais le JSON read-only.
In order to verify how the bussineslogic works and the target can be reached, a simulation environment has been set up producing plots of the simulation.
---
For simplification and beeing able to play with the simulations, a docker tool can be used to run the simulations in a container.
## Conventions
> Note: please install docker before running the script
- Timestamps : `QDateTime` ISO 8601 avec timezone.
- Puissances en **Watts**, courants en **Ampères**. `phasePowerLimit` est en **Ampères par phase**.
- `currentPower` root meter **négatif** = surplus (export).
- `phasePowerLimit = 0` → smart charging désactivé.
- `weighting` d'un `ScoreEntry` : 1.0 = créneau le moins cher, 0.0 = le plus cher.
./docker-simulation.sh
---
## Build, tests, simulations
Once the simulation have finished successfully the plots can be found in the `results/` folder of the source directory.
Build standard nymea (qmake6, cross-compilation arm64 supportée ; publication sur le canal APT `powersync-testing`).
## Test coverage report
Couverture de tests :
In order to build a test coverage report from the energy manager, the source code bust be built with the coverage support enabled.
```sh
qmake CONFIG+=coverage
make -j$(nproc)
# tests dans tests/auto :
# nymeaenergytestcharging
# nymeaenergytestspotmarket
./build-coverage-report.sh -b builddir
```
Add the `qmake` configuration in order to enable it:
Simulations (plots de vérification de la logique métier dans `results/`) :
qmake CONFIG+=coverage
on
make -j$(nproc)
```sh
./docker-simulation.sh # docker requis
```
---
# The tests can be found in tests/auto
nymeaenrgytestcharging
nymeaenrgytestspotmarket
## Roadmap
# Optionally you can also run the simulations to show coverage of the simulation code
nymea-energy-simulation
- **Beta** : arbitrage central généralisé, adaptateur ECS/relais, frontière `requestOptimization` posée, stratégie règles par défaut. Mode `community` : tout fonctionne sans optimiseur.
- **Post-beta** : adaptateurs PAC/HVAC et batterie ; socket + protocole optimiseur documenté (GPL) ; backend propriétaire Héos ; contrainte réseau §14a EnWG comme source de plafond ; tarification FR (Linky TIC, Tempo) ; namespace EMS générique.
- **Amont** : une fois la beta opérationnelle, discussion avec l'équipe nymea sur une intégration possible dans l'arbre upstream.
Once all tests have run successfully, the report can be generated as follows:
---
./build-coverage-report.sh -b builddir
## Licence
The tool opens the generated report in the browser.
`etm-powersync-energy-etm` est sous **GNU General Public License v3.0 ou ultérieure**, comme l'amont dont il dérive. Tout le code de ce repo est GPL — il n'y a, et il n'y aura, aucun composant propriétaire ici. La valeur propriétaire (l'optimiseur **Héos**) vit dans un processus séparé, derrière le socket documenté, et n'est pas distribuée dans ce dépôt.
## License
nymea-energy-plugin-nymea is licensed under the GNU General Public License version 3.0 or (at your option) any later version.
A copy of the license should be included within this repository; if it is missing you can obtain it from <https://www.gnu.org/licenses/gpl-3.0.html>.
Une copie de la licence est incluse dans ce repo ; si elle manque : <https://www.gnu.org/licenses/gpl-3.0.html>.