Compare commits

..

No commits in common. "landing-silo" and "main" have entirely different histories.

51 changed files with 55 additions and 5742 deletions

20
.clangd
View File

@ -1,20 +0,0 @@
# Flags de repli pour les headers ouverts hors contexte compile_commands.json
# (notamment quand le repo est ouvert via le symlink ~/Schreibtisch/ alors que
# compile_commands.json contient les chemins réels ~/projects/).
# Ces flags s'appliquent en fallback ; les entrées compile_commands.json ont priorité.
CompileFlags:
Add:
- -std=c++17
- -Ienergyplugin
- -I/usr/include/nymea
- -I/usr/include/nymea-energy
- -I/usr/include/x86_64-linux-gnu/qt6
- -I/usr/include/x86_64-linux-gnu/qt6/QtCore
- -I/usr/include/x86_64-linux-gnu/qt6/QtGui
- -I/usr/include/x86_64-linux-gnu/qt6/QtNetwork
- -I/usr/lib/x86_64-linux-gnu/qt6/mkspecs/linux-g++
- -DQT_CORE_LIB
- -DQT_NETWORK_LIB
- -DQT_GUI_LIB
- -DQT_PLUGIN
- -D_REENTRANT

3
.gitignore vendored
View File

@ -8,6 +8,3 @@ Makefile
builddir/
*_moc.cpp
autogenerated/
# clangd — chemins absolus du poste local, ne pas versionner
compile_commands.json

482
AGENTS.md
View File

@ -1,477 +1,21 @@
# AGENTS.md — etm-powersync-energy-plugin-etm
Moteur HEMS. Fork GPL de `nymea-energy-plugin-nymea`, étendu de l'optimisation EV
vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
vers un gestionnaire d'énergie complet (ECS, PAC, batterie, relais).
- **Licence** : GPL-3.0 · **Miroir public** : OUI
- **Branche de travail** : `feature/beta-rulebased`
- **Document d'interface faisant autorité** : `docs/OPTIMIZER_PROTOCOL.md` (le contrat
stratégie/arbitrage — interne ET socket). `INTERFACE.md` fait autorité sur l'API JSON-RPC.
- **Agent** : energy-etm · **Branche** : feature/beta-rulebased · **Scope** : energyplugin/
## ÉTAT
## Invariants locaux
1. Tourne SANS `etm-powersync-optimizer` (socket absent → repli stratégie règles).
2. Sécurité jamais déléguée : `verifyOverloadProtection()` (temps réel) borne toute sortie de l'optimiseur.
3. Pas de boucle de feedback : surplus = PV mesurée + compteur, jamais le net.
4. `decisionReason` non vide, en français, sur chaque décision.
5. Aucun composant propriétaire ici (Héos vit dans `etm-powersync-optimizer`).
6. Première tâche (revue) : renommer `nymea-energy-plugin-nymea.pro``.pro` ETM
(+ TARGET, debian/). NE PAS toucher aux noms de paquets publiés.
| Phase | Statut | Commit(s) |
|-------|--------|-----------|
| 0 — analyse fork / structure | ✅ FAITE | `f4d5b20` |
| 1 — renommage .pro + métadonnées debian | ✅ FAITE | `f4d5b20` |
| 2 — design arbitre validé | ✅ FAITE | `074fa71` |
| 3a — structs protocole + interfaces | ✅ FAITE | `4ae1939` |
| 3b — EnergyArbitrator + scheduler + adapter | ✅ FAITE — iso-fonctionnalité prouvée | `5f49e4c`, `d8ebd65`, `[3b-iv]` |
| 3c — EcsRelayAdapter + waterfall ECS | ✅ FAITE — suite 18/18 + charging 46/46 | `6298d5d``54ba229` |
| 3e — SgReadyAdapter | ✅ FAITE — suite 19/19 | `83d5ad9``d8079e8` |
## Références
- `README.md` (architecture), `INTERFACE.md` (fait autorité sur l'API), `etm_powersync_energy.svg`.
**Détail 3b** :
- `EnergyArbitrator : public SmartChargingManager` — justification dans `## DÉCISIONS DE DESIGN`
- `EvAdapter` + `RuleBasedScheduler` implémentés
- Build : **0 erreur / 0 warning**
- `ETM_ARBITRATOR` **actif** dans `energyplugin.pri`
- Iso-fonctionnalité prouvée :
- Simulation : 226 lignes décisions identiques (Theoretically / Surplus / Current load), diff = 0
- Tests charging : 57 lignes décisions identiques, diff = 0 ; 46/46 PASS ref ET ETM
- [Arbitre] présents avec raisons françaises pour les 4 cas (idle, surplus PV, aWATTar, deadline)
**Détail 3c (morceaux déjà compilés, 0 erreur / 0 warning)** :
- **Morceau 0**`LoadAction.force=false` (bypass verrous sécurité) ✅
- **Morceau 1**`EcsRelayAdapter` (.h + .cpp) : pilote N Things powerswitch,
`applyRelayStage()`, verrous `minOnS/minOffS`, bypass si `force==true`
- Enregistrement explicite via `EnergyArbitrator::registerEcsAdapter()` (tests + config)
- **Morceau 2**`buildContext()` : `SurplusMeter` brut (`exportW = max(0, -meter->currentPower())`),
`loads[]` EV + ECS, `SurpusPv` déféré 3d ✅
**3c CLÔTURÉE** (commits `6298d5d` waterfall → `54ba229` testMeterSilentFallback) :
- Morceaux 3-7 faits. Waterfall ECS (tri priorité ASC = rang) + dispatch + watchdog L2
(mode dégradé conservateur, planif suspendue) + degradedMode/notification + tests.
- **Correctif clé** `[3c-3-fix]` : surplus **net signé** (délestage en import) + **clamp
lock-aware** `minStage/maxStage` (protection compresseur) ; **seam de temps unifié**
(`now=ctx.timestamp`, `lockWindow()` source unique) ; watchdog injectable
(`recordMeterUpdate`/`evaluateMeterFreshness`, déclencheurs sous `#ifndef ENERGY_SIMULATION`).
- Tests : `testEcsSurplusPV` (4 régimes) + `testMeterSilentFallback` (stabilité + reprise).
Suite simulation **18/18**, charging **46/46**, plugin prod 0/0.
- **arm64 cross : NON vérifié dans le sandbox dev** (pas de toolchain/Qt6-aarch64/docker)
→ relève de l'infra de build CI (`etm-powersync-deploy`). À confirmer là-bas.
**3e CLÔTURÉE** (commits `83d5ad9` types → `d8079e8` testSgReadySurplus) :
- `SgReadyAdapter` : 4 états normés (kind:State), encodage 2 bits, `lockWindow` symétrique
(`minStateHold`, protection court-cycling PAC), **atomicité de transition** (`transientHarm` :
passe par le neutre/reco, jamais par blocage/forcé — contrat transport déporté).
- Scheduler : **mapping sémantique** (≥P4×1,2→forcé, hystérésis 1,2/1,0 ; ≥P3→reco ; sinon
normal ; état 1 jamais via surplus) + **waterfall UNIFIÉ** ECS+SG-Ready (un seul budget,
trié par priorité). Mode dégradé L2 → état 2 (mains off, jamais blocage ; SAFETY.md corrigé).
- Tests : `testSgReadySurplus` (montée · hystérésis · court-cycling · **budget partagé ECS↔PAC
avec inversion de priorité**) + `testEcsRelayTopologies` (ECS 1 relais + 3 relais
**non-cascadé** 1500→2000, off-before-on) — commit `dfdd988`.
- **DoD 3e** : amd64 0/0 ✓ · simulation **20/20** ✓ · `decisionReason` français (forcé/reco/normal/
verrou) ✓ · arm64 → CI (idem 3c).
- **Audit Doxygen** fait (5 findings → 0 : `\param now`, docs périmées 3c/3e) — commit `51760a7`.
- **`docs/TEST_TERRAIN.md`** créé : procédure Palier 1 (14 tests) pour le banc nymea-dev arm64.
### Ce que le moteur sait faire aujourd'hui
- **Arbitrage central unique** : un budget de surplus net signé, cascade par **priorité** (rang).
- **Charges** : EV (proxy amont, décision B), **ECS** (paliers, `Stage`), **SG-Ready PAC**
(4 états, `State`) — ECS+PAC classables ensemble sur le budget partagé.
- **Sécurité** : protection compresseur (verrous lock-aware `minStage/maxState`, seam de temps
unifié) ; watchdog L2 (compteur muet >90 s → mode dégradé conservateur, planif suspendue,
reprise par recalcul) ; `verifyOverloadProtection()` amont intacte ; `degradedMode` notifié.
- **Local-first** : zéro cloud (invariant 10).
### DÉFÉRÉ (ordre indicatif)
- **Passe README + contrats** : `OPTIMIZER_PROTOCOL.md` ne reflète PAS encore plusieurs ajouts
3c/3e — `LoadAction.force` (bypass sécurité L2), `telemetry.minStage/maxStage` et
`minState/maxState` (fenêtres de verrou), `degradedMode` (notification). À documenter dans
le protocole publié + README architecture. **Prochaine session doc** (pas avant le terrain).
- **Waveshare D8** : plugin DEVICE (8 powerswitch) sous l'adaptateur — **session dédiée**
(chantier transport Modbus RTU/RS485).
- **V2C** (borne EV) : intégration — **session dédiée**.
- **3d** SocketScheduler (handshake/heartbeat/repli optimiseur).
- **3f** BatteryAdapter (constraints + charge réseau plafonnée) + waterfall grid-funding.
- **3g** transplantation EV dans le waterfall unifié → toutes charges classables ensemble.
- **Couche config priorités** (API JSON-RPC + UI Flutter drag-and-drop) — cf. `## ROADMAP`.
Note : la déclaration des adaptateurs est aujourd'hui **codée** (`energypluginnymea.cpp`),
pas configurable à chaud — cf. `docs/TEST_TERRAIN.md` §1.d.
- **arm64** cross-compile validé en pré-déploiement (infra CI `etm-powersync-deploy`).
- **`Doxyfile` + job CI `doxygen -W`** (automatiser l'audit doc).
**PROCHAINE ACTION** : **test terrain vendredi** (`docs/TEST_TERRAIN.md`, Palier 1), puis
**passe contrats** (OPTIMIZER_PROTOCOL + README).
**Remotes git** :
- `origin` (`https://git.etm-powersync.fr/...`) = remote de travail — push normal
- `etm-public` (`gitea-lan:...powersync-energy-plugin-etm`) = miroir public GPL → push **MANUEL par Patrick uniquement** (`sync-public.sh`)
- `etm-pro` = reliquat historique — ne pas utiliser, cartographie à clarifier
---
## INVARIANTS BUILD / PACKAGING (ne pas re-questionner)
### Nom et version du paquet Debian
| Champ | Valeur | Raison |
|---|---|---|
| **Source / Package** | `powersync-energy-plugin-nymea` | Fork ETM Voie B (FORK-WORKFLOW.md) — distingué de l'amont `nymea-energy-plugin-nymea` |
| **Version** | `<nymea>+etmN` (N incrémenté à chaque release) | Ex. `1.15.2+etm1` si la box cible tourne nymea 1.15.2 |
| **Distribution** | `trixie` | OS cible hems (arm64 Debian 13 Trixie) |
| **Provides/Conflicts/Replaces** | `nymea-energy-plugin-nymea` | Voie B obligatoire : remplace l'amont proprement, sans double chargement |
| **TARGET `.so`** | `libnymea_energypluginnymea.so` | Inchangé (nom de chargement nymea fixé par l'amont) |
### Build cross arm64
- **Hôte** : conteneur LXD `build-cross-arm64` sur `etm-powersync-dev` (amd64)
- **Commande** : `dpkg-buildpackage -aarm64 -b -uc -us` dans `/root/etm-powersync-energy-plugin-etm`
- **Dépendances arm64 requises** : `libnymea-dev:arm64`, `libnymea-energy-dev:arm64`,
`libnymea-tests-dev:arm64`, `nymea-experience-plugin-energy:arm64`,
`libqt5websockets5-dev:arm64`, `qtbase5-dev:arm64` — toutes en `1.15.0`
- **`lrelease`** : fourni par `qttools5-dev-tools` (amd64) — nécessaire si absent du conteneur
- **Ne pas builder sur le Pi** : Zero 2 W, 512 Mo → OOM garanti
### Tests hors paquet deb
- Les tests (`tests/`) ne sont **pas compilés** dans le `.deb` de prod.
- `etm-powersync-energy-plugin-etm.pro` : tests sous `build_tests { SUBDIRS += tests }` uniquement.
- `debian-qt5/rules` : `DH_OPTIONS=-N nymea-energy-tests` — exclut le paquet tests de tous les outils dh.
- Pour compiler les tests localement : `qmake CONFIG+=build_tests && make`.
- Cause racine : `libnymea-tests:arm64` 1.15.0 n'exporte pas `enableNotifications` → link fail cross.
### Vérification post-build obligatoire
```bash
dpkg-deb -I powersync-energy-plugin-nymea_*.deb | grep -E "Architecture|Version|Depends|Provides|Conflicts|Replaces"
# Attendu : Architecture: arm64 ; Version: <nymea>+etm* aligné sur la box cible
# Depends: libnymea-energy (>= <nymea>...) — la version doit matcher la box
# Provides/Conflicts/Replaces: nymea-energy-plugin-nymea (Voie B obligatoire)
```
---
## PLAN 3C (validé, morceaux 0-2 déjà compilés)
Plan approuvé par Patrick. Corrections A (double déduction EV) et B (anti-clignotement ECS)
**intégrées** dans le design ci-dessous.
### Morceaux déjà compilés (0 erreur/warning)
| # | Fichier(s) | Ce qui a été fait |
|---|-----------|-------------------|
| 0 | `types/loadaction.h` | `bool force = false` — bypass verrous sécurité |
| 1 | `adapters/ecsrelayadapter.h/.cpp` | Adaptateur N-paliers powerswitch, anti-rebond, `applyRelayStage()`, `etm.pri` |
| 2 | `energyarbitrator.h/.cpp` | `buildContext()` : `SurplusMeter` brut, `loads[]` EV+ECS, `registerEcsAdapter()`, `m_ecsAdapters` |
### Morceaux à venir
**3 — Waterfall ECS dans `RuleBasedScheduler::getPlan()`**
Après la boucle EV proxy, ajouter :
```
// Déduction unique (correction A) — ctx.meter.exportW = mesure brute
evReservedW = Σ EV en charge dans slot.actions : max(0, commandedA×phases×230 ev->currentPower())
remainingSurplusW = max(0, ctx.meter.exportW evReservedW)
// Tri loads ECS par priorité ASC (priority 1 = servi en premier ; protocole §5 + annexe C)
pour chaque LoadContext lc où lc.adapter == "relay-stages" :
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW // correction B anti-clignotement
bestStage = palier le plus haut dont stages[i] ≤ budgetCharge
reason = "Surplus PV — ECS palier N (W)" ou "Surplus insuffisant — ECS éteint"
remainingSurplusW = remainingSurplusW + lc.telemetry.currentPowerW stages[bestStage]
// Grid funding : dormant jusqu'à 3f (commente, n'implémente pas)
```
**4 — `syncAdapters()` extension + `applyActionsToAdapters(Slot)` dans `update()`**
- `syncAdapters()` : commentaire "découverte ECS via interface 'ecsrelay' déférée 3g"
- `applyActionsToAdapters(Slot)` : itère slot.actions, dispatche via `m_ecsAdapters` pour kind==Stage
**5 — Watchdog L2** (cf. SAFETY.md §L2)
- `QTimer m_meterWatchdog` 30 s — picoté sur `powerBalanceChanged` SIGNAL pour `m_lastMeterUpdate`
- `onMeterWatchdogTick()` : si `now m_lastMeterUpdate > 90 s``applyDegradedMode()`
- `applyDegradedMode()` : ECS stage 0 force=true + EV courant minimum, reason="Compteur muet..."
**6 — `degradedMode()` + notification + INTERFACE.md**
- `virtual bool degradedMode() const` dans `SmartChargingManager` (retourne false, `// [ETM]`)
- Override dans `EnergyArbitrator`
- Champ `"degradedMode": bool` dans `ChargingSchedulesChanged` (additif, rétro-compatible)
- Mise à jour `docs/INTERFACE.md` + limite dans `docs/SAFETY.md` : "valeur strictement constante non détectée"
**7 — Mock powerswitch + tests**
- Mock JSON : ThingClass `mockPowerSwitch` (état `power` bool, état `currentPower` double)
- `energytestbase.h` : `mockPowerSwitchThingClassId`
- `testEcsSurplusPV` : cas normal + cas "ECS déjà au palier 1, surplus stable → Y RESTE" (anti-clignotement)
- `testMeterSilentFallback` : compteur muet 90 s → mode dégradé → ECS off ; **vérifier que
l'ECS RESTE à 0 sur plusieurs cycles `update()` pendant le silence** (planification
suspendue, pas de rallumage sur cache mort) ; dégel → reprise normale + `degradedMode=false`.
Repli EV : seul un VE *déjà en charge* est clampé au minimum (pas d'activation forcée).
Seam de test : piloter `onMeterWatchdogTick()` / `m_lastMeterUpdate` sans 90 s d'horloge réelle.
### Arithmétique du budget (corrections A + B)
**Correction A — déduction EV unique, dans le scheduler** :
```
exportW dans ctx.meter = mesure brute (invariant 8, protocole §5)
evReservedW = déduction dans getPlan() APRÈS proxy EV, avec m_chargingActions fraîches
Pas de déduction dans buildContext().
```
Exemple : PV 9 kW, EV stable 7360 W, export mesuré 1140 W → evReservedW=0, budget ECS=1140 W ✓
**Correction B — anti-clignotement par recrédit de la conso actuelle** :
```
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW
```
Identique à SCM ~l.1245 pour l'EV. Sans ce recrédit : ECS palier 1 → export chute → palier 0 → oscillation.
---
> ⚠️ Tout plan antérieur mentionnant « créer etm/ avec PowerSyncClient et
> StaticHcHpProvider comme première étape » ou « injecter l'optimiseur dans
> SmartChargingManager » est **INVALIDE et ABANDONNÉ**. Ne pas le reprendre,
> quelle qu'en soit la source (fichier, mémoire de session, contexte).
---
## ARCHITECTURE CIBLE (non négociable)
```
┌──────────────────────────────┐
│ ARBITRAGE CENTRAL │ ← généralisation du
│ budget de surplus UNIQUE │ SmartChargingManager amont
│ waterfall par priorités │
└──────┬───────────────────────┘
│ IScheduler (= contrat OPTIMIZER_PROTOCOL)
┌───────────┴───────────┐
RuleBasedScheduler SocketScheduler
(in-process, V1, GPL) (client unix://|tcp://, V1 aussi —
plan à 1 créneau personne en face en beta : repli rules)
│ distribue le budget en LoadAction typées
┌──────────┬────┴─────┬──────────────┐
EvAdapter EcsRelayAdapter SgReadyAdapter BatteryAdapter
(setpoint, (stage 0/1/2) (state 1-4) (constraint +
iface setpoint W réseau)
evcharger
nymea)
```
Règles absolues :
1. **UN seul arbitre.** Le budget de surplus est une ressource unique, arbitrée à UN
endroit. **INTERDIT : managers frères par type de charge** (EcsManager,
BatteryManager à côté du SmartChargingManager) — deux décideurs sur le même surplus
= sur-engagement et oscillations.
2. **Les LoadAdapters exécutent, ils ne décident pas.** Un adaptateur : parle à son
matériel, déclare ses capacités/contraintes (`declared`, `limits`, types d'action),
expose sa télémétrie, applique les `LoadAction` reçues. Aucune logique de
répartition dedans.
3. **Le SmartChargingManager amont est EV-spécifique** : il se GÉNÉRALISE en arbitrage
multi-charges (`ChargingAction``LoadAction`, bornes EV → adaptateurs). On ne
branche PAS l'optimiseur dans le manager EV tel quel.
4. **La boucle de sécurité est intouchable** : `verifyOverloadProtection()` (temps
réel) + bornes par adaptateur écrêtent TOUTE sortie de stratégie, interne ou socket.
5. **Plan par créneaux** (OPTIMIZER_PROTOCOL §6) : seul le créneau courant est exécuté.
Le rule-based répond un plan à 1 créneau. Modèle async = **cache** : le plan du
cycle précédent s'applique, le recalcul se fait en fond. Jamais d'attente dans
`update()`.
6. **Repli toujours fonctionnel** : optimiseur absent/mort/abstain → rule-based.
Capabilities (`tier`, `optimizerExpected`, `optimizerAlive`, `activeStrategy`)
reflètent l'état en continu.
7. **`decisionReason` non vide, en français, sur chaque action.** Action sans reason
= rejetée.
8. **Pas de boucle de feedback** : surplus = PV mesurée + compteur, jamais le net
après pilotage.
9. **Aucun composant propriétaire ici** (Héos = repo privé `etm-powersync-optimizer`).
Ce repo doit compiler et tourner seul, GPL pur.
10. **ZÉRO cloud** — aucun appel réseau sortant vers un service distant (ni n8n, ni mail,
ni push tiers). Le système fonctionne sans internet (autoconsommation, local-first).
Toute alerte est **locale** : notification nymea in-app + signalisation physique
(buzzer/relais via règle nymea). Le moteur expose l'état, il ne contacte personne.
Exception : le plugin est CLIENT d'un optimiseur sur socket local/LAN (OPTIMIZER_PROTOCOL,
`unix://` ou `tcp://` du réseau de l'installation) — jamais un service cloud externe.
## RÉPONSES FIGÉES (ne plus poser ces questions)
- Plages HC/HP et tarifs : **configuration JSON**, jamais hardcodé. Prévoir Tempo
(6 types de jours), pas seulement HC/HP.
- Async : **modèle cache** (cf. règle 5).
- Bugs upstream : **commits séparés** du code ETM, message préfixé `[upstream-fix]`.
Candidats PR nymea (fix phases EV, Keba) = patchs isolés, propres, upstreamables.
- `protocolVersion` : **constante `"1.0"`**, pas un paramètre de config.
- Renommage : FAIT (Phase 1, commit f4d5b20). TARGET et noms de paquets debian
INCHANGÉS (.so drop-in remplaçant l.amont — garantit un seul plugin énergie chargé).
## WORKFLOW OBLIGATOIRE
Chaque phase produit un livrable VALIDÉ PAR PATRICK avant la suivante. Jamais de code
avant validation du design de la phase.
- **Phase 0 — Analyse (en cours)** : répondre par écrit, code lu à l'appui :
(a) quelles charges SmartChargingManager pilote-t-il (types manipulés) ;
(b) ChargingAction peut-il exprimer « ECS palier 1 » / « batterie décharge
interdite » — citer ses champs ; (c) avec des managers séparés, où vivrait le
budget unique. Zéro code, zéro plan d'implémentation.
- **Phase 1 — Renommage** : `git mv` du `.pro`, TARGET, debian/. Un commit, revue.
- **Phase 2 — Design de l'arbitrage généralisé** : interface `LoadAdapter` (méthodes,
ce qu'un adaptateur déclare), flux du budget, mapping `LoadAction`→adaptateurs,
où vit `IScheduler`. Texte + signatures, pas d'implémentation. Validation Patrick.
- **Phase 3 — Implémentation par étapes** (chacune : compile amd64 + cross arm64,
et un scénario `docker-simulation.sh` qui la prouve = DoD) :
3a. structs du protocole (contexte, plan, actions) ;
3b. arbitre + RuleBasedScheduler + EvAdapter (iso-fonctionnel avec l'amont sur EV) ;
3c. EcsRelayAdapter (paliers) ; 3d. SocketScheduler (handshake/heartbeat/repli,
testé contre un optimiseur factice ~50 lignes) ; 3e. SgReadyAdapter ;
3f. BatteryAdapter (constraints + charge réseau plafonnée).
- **Bugs upstream** : au fil de l'eau, commits `[upstream-fix]` séparés.
## DÉCISIONS DE DESIGN (écarts et justifications)
### 3b révisé — délégation EV à l'amont (beta assumée)
**Décision Patrick** : hybride étagé pour la beta.
**En beta** : les décisions EV restent dans les méthodes amont
`planSurplusCharging` / `planSpotMarketCharging` (`SmartChargingManager`), inchangées.
`RuleBasedScheduler::getPlan()` les appelle en **proxy** et reformate leurs sorties
(`ChargingActions`) en `LoadAction` pour le log `[Arbitre]`.
`EvAdapter::applyAction()` est **inactif** jusqu'à 3g — mais `descriptor()` et
`telemetry()` sont utilisés dès maintenant pour le `SurplusContext`.
**Pipeline ETM réel** (waterfall budget Surplus/Grid, `applyAction`) arrive en **3c**
pour les charges non-EV (ECS, SG-Ready), alimenté par le surplus *restant* après
déduction de l'`addedPower` des consignes EV du cycle courant (pas encore visible
au compteur).
**Limitations beta assumées** :
- EV toujours prioritaire ; waterfall appliqué uniquement aux charges non-EV.
- Le classement drag-and-drop (priorités) ne portera que sur les charges non-EV.
**Étape 3g (post-beta)** : transplantation réelle de la logique EV dans
`RuleBasedScheduler` → priorités libres entre toutes les charges (EV, ECS, SG-Ready,
batterie).
**Dette 3g — convention de priorité EV** : `EvAdapter::descriptor()` met
`priority = 100` (`evadapter.cpp:24`), reliquat de l'ancienne convention « poids,
valeur haute = premier ». Inoffensif en beta : l'EV est servi par le proxy *avant* le
waterfall ECS et n'entre pas dans le tri ECS (ascendant, rang 1 = premier servi,
protocole §5). À reconcilier quand l'EV rejoindra le waterfall unifié : la priorité
devient un **rang** (1, 2, 3…), pas un poids — sinon `priority=100` placerait l'EV en
dernier d'un tri ascendant.
---
### 3b-iii — EnergyArbitrator hérite de SmartChargingManager
**Design validé en session** : "nouvelle classe dans etm/, n'étend pas SmartChargingManager".
**Écart implémenté** : `EnergyArbitrator : public SmartChargingManager`.
**Justification** :
1. **Contrainte NymeaEnergyJsonHandler** : ce handler amont prend un
`SmartChargingManager*` dans son constructeur.
Sans héritage, toute solution propre (interface commune, pointeur générique)
nécessiterait de modifier `nymeaenergyjsonhandler.h/.cpp` — violation de la règle
"Modifier le code amont uniquement pour corriger des bugs".
2. **verifyOverloadProtection() intacte** : héritée bit-pour-bit, connectée aux mêmes
signaux via le constructeur du parent. Zéro risque de régression sur la sécurité.
3. **simulationCallUpdate() polymorphe** : appelle `update()` virtuel → redirige
automatiquement vers `EnergyArbitrator::update()`. Les tests amont passent sans
modification.
4. **Minimal upstream diff** : seuls les attributs `protected`/`virtual` changent dans
`smartchargingmanager.h` (marqués `// [ETM]`). Zéro logique upstream modifiée.
**Risque accepté** : `EnergyArbitrator` a accès à l'état privé de SCM via les
accesseurs `internal*`. La discipline AGENTS (LoadAdapters exécutent, ne décident pas ;
un seul arbitre) compense. Si SCM était refactorisé en amont pour exposer une interface
publique propre, l'héritage pourrait être remplacé par composition.
---
### Verrous minOn/minOff — protection compresseur (décision Patrick)
Le délestage du waterfall est **strict au niveau budget** (surplus net signé : en import,
budget négatif → palier 0). Mais une charge à compresseur (PAC, ballon thermodynamique)
ou un VE ont un **temps de fonctionnement minimum incompressible** : ce n'est pas du
confort, c'est de la **protection matérielle** (le court-cycling détruit le compresseur).
**Séparation des responsabilités** :
- Le **scheduler** décide le palier idéal selon le budget (peut vouloir « palier 0 »).
- L'**adaptateur** borne ce choix via `minStage`/`maxStage` (fenêtre `lockWindow()` évaluée
au temps de cycle) : une charge verrouillée ON garde son palier ; l'import transitoire
est **borné par minOn**, pas illimité. Le scheduler clampe et décrémente le budget au
palier réel (puissance engagée non-coupable) → budget correct pour les charges suivantes.
- `minOnS`/`minOffS` sont des **paramètres par charge** (constructeur `EcsRelayAdapter`,
config installateur) — **jamais codés en dur**.
**Défauts indicatifs par type** (à affiner à la mise en service) :
| Type de charge | minOn | minOff | Raison |
|----------------|-------|--------|--------|
| Ballon résistif (ECS simple) | ~60 s | ~60 s | anti-rebond relais seul |
| Ballon thermodynamique / PAC | ~300600 s | ~300 s | **protection compresseur** (anti court-cycling) |
| SG-Ready PAC (3e) | `minStateHoldS` ~900 s | — | maintien d'état imposé constructeur |
**Seam de temps** : `minStage`/`maxStage` (décision) ET le verrou de `applyAction`
(exécution) partagent le **même `now = ctx.timestamp`** via `lockWindow()` — source unique,
divergence impossible par construction, injectable en simulation. Voir `iloadadapter.h`
(contrat « temps = paramètre, jamais l'horloge »).
---
## MODÈLE DE SÉCURITÉ (décision Patrick — immuable)
Cinq couches indépendantes. Chacune est conçue pour qu'une défaillance des couches
supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour le détail.
| Couche | Qui | Quoi |
|--------|-----|------|
| **L0** | Disjoncteur / Linky matériel | Coupure physique — hors logiciel |
| **L1** | Failsafe natif des bornes | Config installateur, checklist ETM |
| **L2** | Watchdog fraîcheur compteur (à coder en 3c) | `QTimer` piloté : si `lastMeterUpdate > 90 s` → mode dégradé (EV min/off, ECS off, pas de charge réseau batterie), `decisionReason` explicite, notification nymea. Scénario simulation dédié : "compteur muet → repli". |
| **L3** | Watchdog systemd sur nymead | Repo `etm-powersync-deploy`, hors scope ici |
| **L4** | Logique signal-driven existante | Boucle `update()` déclenchée par événements |
**Règles de code** :
- Le watchdog L2 est piloté par **`QTimer`** (pas par signal `meterChanged`) pour
rester actif même si le signal ne fire plus.
- Mode dégradé = consignes **de repli** (EV au minimum si pluggedIn, ECS off, etc.)
+ `decisionReason` non vide + notification `EnergyManagerChanged` avec `degradedMode`.
- `verifyOverloadProtection()` (L4) est déclenchée par **deux mécanismes** :
(a) signal `powerBalanceChanged` (temps réel — SCM.cpp ligne 127, mécanisme principal) ;
(b) appel cyclique en position 3 d'`update()` (SCM.cpp ligne 313, filet périodique).
La position dans `update()` est **INTOUCHABLE** — même dans `EnergyArbitrator::update()`.
---
## DÉFINITION DE FAIT (par étape de phase 3)
1. Compile amd64 et cross arm64.
2. Scénario de simulation ajouté/étendu qui démontre le comportement (le harnais
`docker-simulation.sh` + `tests/auto` hérités sont le banc de test).
3. `decisionReason` visibles dans les logs de simulation.
4. Aucune régression des tests amont existants.
5. Toute classe/méthode **publique** de `etm/` porte un commentaire Doxygen :
`\brief`, `\param`, `\return`, et surtout le **contrat de comportement**
(invariants, écrêtage, hypothèses que l'appelant peut faire).
Les headers 3a servent de modèle — les convertir au format Doxygen lors du passage 3b.
## ROADMAP — configuration des priorités par l'utilisateur (post-beta)
- **ACQUIS (3e)** : le waterfall trie déjà ECS + SG-Ready ensemble par `priority` (rang
ASC, 1 = servi en premier), **budget unifié** qui cascade à travers toutes ces charges.
- **LIMITE beta** : le VE reste **hors du tri** (décidé par l'amont *avant* le waterfall,
décision B) → on ne peut pas classer le VE derrière l'ECS.
- **MANQUE pour des priorités réglables par le client** :
- (a) **3g** : transplanter le VE dans le waterfall unifié → toutes les charges
(VE, ECS, SG-Ready, batterie) classables ensemble.
- (b) **Couche « config priorités »** : exposer et persister le `priority` de chaque
charge via l'API JSON-RPC, modifiable depuis l'app Flutter (drag-and-drop des
priorités de l'UI). C'est le **pont moteur↔UI**, un morceau à part entière
(ni 3e ni 3g).
- **État actuel** : `priority` est fixé à la création de l'adaptateur (`register*Adapter`),
pas d'interface de réglage à chaud.
- **Argument démo nymea** : le client réordonne ses charges, le surplus suit.
## RÉFÉRENCES
- `docs/OPTIMIZER_PROTOCOL.md` — le contrat. §5 (SurplusContext), §6 (plan/actions),
§7 (repli), annexe C (priorités).
- `README.md` — architecture (deux boucles, frontière), `etm_powersync_energy.svg`.
- `INTERFACE.md` — API JSON-RPC existante (`NymeaEnergy`, cible future `Ems`).
- Carte globale du workspace : `../AGENTS.md`.
Carte globale et frontières : voir `../AGENTS.md`.

View File

@ -453,18 +453,10 @@ S'abonner via `JSONRPC.SetNotificationStatus` avec le namespace `"NymeaEnergy"`.
---
### `NymeaEnergy.ChargingSchedulesChanged`
Émis à chaque recalcul du planning (cycle ~1 min), **et** à chaque transition du mode
dégradé L2 (watchdog fraîcheur compteur).
Émis à chaque recalcul du planning (cycle ~1 min).
```json
{
"chargingSchedules": [ ... ],
"o:degradedMode": false
}
{ "chargingSchedules": [ ... ] }
```
- `degradedMode` *(bool, optionnel — [ETM])* : `true` quand le compteur est muet depuis
> 90 s et que les consignes de repli L2 sont actives (planification suspendue, ECS coupé,
EV en charge clampé au minimum). Repasse à `false` au retour du compteur. Champ additif :
les clients antérieurs l'ignorent. Détail : `docs/SAFETY.md` §L2.
---

2
debian
View File

@ -1 +1 @@
debian-qt6
debian-qt5

View File

@ -1,34 +1,3 @@
powersync-energy-plugin-nymea (1.15.2+etm2) trixie; urgency=medium
* Charge pilotée rév. 3 — frontière optimiseur (watt-pur) ↔ routeur (watts→relais, experience-plugin).
* RelayRouter : combinatoire watts→relais ; paliers DÉRIVÉS des relais (things "power"), verrous
minOn/minOff internes, off-before-on, agrégation currentPowerW (mesuré sinon nominal commandé).
* Optimiseur watt-pur : suppression du kind Stage ; buildSetpointAction (arrondi powerLevels / résidu).
* LoadConfig : forme relay-router (relays[]=[{thingId,powerW}] + minOnS/minOffS), validation
conditionnelle ; Get/SetLoadConfig (schéma SET "o:" pour les deux formes exclusives).
* Repli L2 unifié : compteur muet → Setpoint(0) force=true sur tous les ILoadAdapter (RelayRouter
coupe TOUS les relais). EtmVariableLoadAdapter (continu/triac) conservé.
* Tests : combinatoire/off-before-on/déduplication, repli L2 relais, chaîne config→routeur, RPC.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 28 Jun 2026 13:38:36 +0200
powersync-energy-plugin-nymea (1.15.2+etm1) trixie; urgency=medium
* Build contre nymea 1.15.2. EcsRelayAdapter + SgReadyAdapter dans init() (§1.d banc hems).
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 25 Jun 2026 15:30:00 +0200
powersync-energy-plugin-nymea (1.15.0+etm1) trixie; urgency=medium
* Renommage paquet powersync-* + version 1.15.0 alignée sur libnymea-energy cible hems
* EnergyArbitrator : waterfall ECS+PAC, RuleBasedScheduler, watchdog L2
* EcsRelayAdapter : 8 paliers binaires, off-before-on, verrous minOn/minOff
* SgReadyAdapter : 4 états SG-Ready, hystérésis kForceMargin=1.2, zone morte
* §1.d banc hems : enregistrement EcsRelayAdapter+SgReadyAdapter dans init()
* Build deb : tests exclus (CONFIG+=build_tests requis séparément)
-- Patrick Schurig / ETM PowerSync <etm.schurig@gmail.com> Wed, 25 Jun 2026 12:00:00 +0200
nymea-energy-plugin-nymea (1.13.0+etm1) jammy; urgency=medium
* Fork ETM PowerSync de nymea-energy-plugin-nymea

View File

@ -1,9 +1,9 @@
Source: powersync-energy-plugin-nymea
Source: nymea-energy-plugin-nymea
Section: utils
Priority: optional
Maintainer: Patrick Schurig / ETM PowerSync <etm.schurig@gmail.com>
Standards-Version: 4.6.0
Homepage: https://git.etm-powersync.fr/ETM-Schurig/etm-powersync-energy-plugin-etm
Homepage: https://nymea.io
Build-Depends: debhelper (>= 9.0.0),
dpkg-dev (>= 1.16.1~),
libnymea-dev (>= 0.17),
@ -18,21 +18,16 @@ Build-Depends: debhelper (>= 9.0.0),
gnuplot,
Package: powersync-energy-plugin-nymea
Package: nymea-energy-plugin-nymea
Section: libs
Architecture: any
Multi-Arch: same
Depends: ${shlibs:Depends},
${misc:Depends},
nymea-experience-plugin-energy,
Provides: nymea-energy-plugin-nymea
Conflicts: nymea-energy-plugin-nymea
Replaces: nymea-energy-plugin-nymea
Description: ETM PowerSync energy plugin for nymea (fork)
Fork ETM PowerSync de nymea-energy-plugin-nymea. Ajoute un arbitrage multi-charges
(ECS paliers, SG-Ready PAC, EV, batterie) avec waterfall unifié par priorité,
anti-clignotement (recrédit correction B), watchdog L2 compteur muet, et
mode dégradé conservateur.
Description: nymea energy plugin for the energy experience
This nymea energy plugin adds the smart smart charging and overload protection
to the nymea energy experience.
Package: nymea-energy-tests
@ -42,4 +37,3 @@ Multi-Arch: same
Depends: ${shlibs:Depends},
${misc:Depends},
Description: Tests and simulations for nymea:energy
Built separately with CONFIG+=build_tests ; not included in prod deb builds.

View File

@ -3,9 +3,6 @@
export DH_VERBOSE=1
export QT_QPA_PLATFORM=minimal
# nymea-energy-tests n'est pas buildé dans le paquet de prod (tests compilés
# séparément via CONFIG+=build_tests). Exclure globalement de tous les outils dh.
export DH_OPTIONS=-N nymea-energy-tests
DEB_HOST_MULTIARCH ?= $(shell dpkg-architecture -qDEB_HOST_MULTIARCH)

View File

@ -1,9 +1,9 @@
Source: powersync-energy-plugin-nymea
Source: nymea-energy-plugin-nymea
Section: utils
Priority: optional
Maintainer: Patrick Schurig / ETM PowerSync <etm.schurig@gmail.com>
Standards-Version: 4.7.2
Homepage: https://git.etm-powersync.fr/ETM-Schurig/etm-powersync-energy-plugin-etm
Homepage: https://nymea.io
Build-Depends: debhelper,
dpkg-dev,
qt6-base-dev,
@ -18,21 +18,16 @@ Build-Depends: debhelper,
gnuplot-x11,
Package: powersync-energy-plugin-nymea
Package: nymea-energy-plugin-nymea
Section: libs
Architecture: any
Multi-Arch: same
Depends: ${shlibs:Depends},
${misc:Depends},
nymea-experience-plugin-energy,
Provides: nymea-energy-plugin-nymea
Conflicts: nymea-energy-plugin-nymea
Replaces: nymea-energy-plugin-nymea
Description: ETM PowerSync energy plugin for nymea (fork Qt6)
Fork ETM PowerSync de nymea-energy-plugin-nymea. Ajoute un arbitrage multi-charges
(ECS paliers, SG-Ready PAC, EV, batterie) avec waterfall unifié par priorité,
anti-clignotement (recrédit correction B), watchdog L2 compteur muet, et
mode dégradé conservateur.
Description: nymea energy plugin for the energy experience
This nymea energy plugin adds the smart smart charging and overload protection
to the nymea energy experience.
Package: nymea-energy-tests

View File

@ -1,2 +1,2 @@
usr/lib/@DEB_HOST_MULTIARCH@/nymea/energy/libnymea_energypluginnymea.so
usr/share/nymea/translations/nymea-energy-plugin-nymea-*.qm
energyplugin/translations/*qm usr/share/nymea/translations/

View File

@ -3,7 +3,6 @@
export DH_VERBOSE=1
export QT_QPA_PLATFORM=minimal
export DH_OPTIONS=-N nymea-energy-tests
DEB_HOST_MULTIARCH ?= $(shell dpkg-architecture -qDEB_HOST_MULTIARCH)

View File

@ -1,238 +0,0 @@
# INTERFACE charge pilotée — contrat · **rév. 3**
> **Source de vérité unique.** Déposé **à l'identique dans les deux repos**
> (`etm-powersync-app` et `etm-powersync-energy-plugin-etm`). Toute divergence se tranche **ici**,
> pas dans le code. Un agent qui a besoin d'un champ absent met à jour ce doc **d'abord**, dans
> les deux repos.
>
> ### ⚠️ rév. 3 — déplacement de frontière (lire avant de coder)
> La rév. 2 plaçait la combinatoire matérielle « dans le thing ». **Vérification faite : un
> integration-plugin nymea ne peut PAS piloter les things d'un autre plugin** (`thingManager`
> y est privé ; seuls le cœur, le moteur de règles, les scripts et les **experience-plugins**
> commandent des things tiers). Donc la combinatoire watts→relais **ne peut vivre que côté
> experience-plugin** — là où l'arbitre a déjà le `ThingManager`.
>
> **rév. 3 acte cette réalité** : un relais est un **thing `power` nu** (comme n'importe quel
> relais Modbus/MQTT) ; la combinaison watts→relais vit dans une couche **ROUTEUR** distincte,
> **sous** l'optimiseur, côté experience-plugin. L'optimiseur reste **watt-pur** et ne connaît
> **jamais** un relais. **Impact app majeur en §10 — l'écran « Configurer » change.**
---
## 1. Principe & frontière (rév. 3)
**Trois couches, la frontière entre OPTIMISEUR et ROUTEUR.**
| Couche | Où | Raisonne en | Connaît |
|---|---|---|---|
| **Optimiseur** | `RuleBasedScheduler` / `SocketScheduler` (GPL ou socket) | **watts** | priorités, `powerLevels` (W), surplus, besoins. **Jamais** un relais. |
| **— FRONTIÈRE —** | | | l'optimiseur émet `LoadAction{Setpoint, powerW}` ; rien d'autre ne descend |
| **Routeur** | couche adaptateur, **experience-plugin** (a le `ThingManager`) | watts → **relais** | la liste de relais, la combinatoire, `minOn/minOff`, l'agrégation `currentPowerW` |
| **Relais** | things `power` (plugins GPIO / Modbus / MQTT) | on/off | leur propre matériel |
> **Invariant directeur (rév. 3).** L'**optimiseur** raisonne uniquement en watts : aucun relais,
> aucun index de combinaison ne franchit la frontière vers le haut. Le **routeur** traduit le
> setpoint W en commutation de relais et n'expose vers le haut que des watts (`powerLevels`,
> `currentPowerW`). **L'optimiseur décide de la puissance ; le routeur décide des relais.**
---
## 2. Portée
| Charge | Couche d'exécution | Nature |
|---|---|---|
| **ECS résistif multipalier** | **`RelayRouter`** (rév. 3) | N relais `power` → combinaison watts |
| **ECS simple on/off** | `RelayRouter` (cas dégénéré : 1 relais, niveaux `[0, nominal]`) | 1 relais `power` |
| **EV charger / Routeur PV / triac** (modulation continue) | **`EtmVariableLoadAdapter`** (inchangé depuis rév. 2) | 1 thing à puissance pilotable, setpoint direct |
| **PAC SG-Ready** | `SgReadyAdapter` (modèle à **états**, §8) | **exclue** — pas une charge en watts |
Les deux exécuteurs (`RelayRouter`, `EtmVariableLoadAdapter`) parlent à l'optimiseur **la même
langue** : `LoadAction{Setpoint, powerW}`. Chacun exécute selon sa nature — combinaison de relais
pour l'un, écriture d'un setpoint continu pour l'autre. Un triac n'est **pas** un routeur de
relais (pas de combinatoire) : on ne le force pas dans ce moule.
---
## 3. Les exécuteurs sous l'optimiseur
### 3.a Cas relais (ECS multipalier) — `RelayRouter`
- **Pas de thing `etmvariableload` unique.** La charge logique est une **liste de relais `power`**.
- Chaque relais est un thing implémentant l'interface nymea **`power`** (state `power` bool
writable ; optionnellement `currentPower` W en lecture). Fourni par le plugin device
(GPIO/Modbus/MQTT) — **rien de spécifique ECS dans ce plugin**.
- Le `RelayRouter` (experience-plugin) :
1. reçoit `LoadAction{Setpoint, powerW}` de l'optimiseur ;
2. **mappe** `powerW` → la combinaison de relais la plus haute ≤ `powerW` ;
3. applique l'anti-rebond **`minOn`/`minOff`** (ici, pas dans le thing) et l'ordre
**off-before-on** (pas de sur-puissance transitoire sur une transition non-cascadée) ;
4. commande chaque relais via `ThingManager::executeAction` (state `power`) ;
5. **agrège `currentPowerW`** = somme des `currentPower` des relais ON, **ou** à défaut de
mesure (relais GPIO bool sans wattmètre) la **somme des nominaux commandés**. Dans ce
dernier cas `currentPowerW` reflète le *commandé*, pas le *mesuré* — acceptable, à garder
en tête pour le diagnostic terrain.
- **`powerLevels`/`maxPowerW` sont DÉRIVÉS** des combinaisons atteignables de la liste de
relais — **source de vérité unique = les relais**. Le routeur les calcule et les expose à
l'optimiseur ; l'assistant app fait le même calcul pour l'affichage.
### 3.b Cas continu (EV / routeur PV) — interface device `etmvariableload`
Inchangé rév. 2 : 1 thing exposant un state `powerSetpoint` (write) + `currentPowerW` (read),
piloté par `EtmVariableLoadAdapter`. (`maxPowerW`/`powerLevels` en read optionnels.)
| State | Accès | Type | Sémantique |
|---|---|---|---|
| `powerSetpoint` | **write** | uint W | consigne de l'optimiseur |
| `currentPowerW` | read | uint W | puissance réellement appliquée |
| `maxPowerW` | read | uint W | plafond physique |
---
## 4. `LoadConfig` — config app → energymanager (rév. 3)
Émis par l'écran « Configurer charge pilotée » ; persisté **côté plugin**
(`/var/lib/nymea/energy-load-configuration.json`), jamais en SharedPreferences. App = éditeur :
`NymeaEnergy.GetLoadConfig` / `SetLoadConfig`.
### Cas relais (`adapter: "relay-router"`, mode `fixed`)
```jsonc
{
"id": "ecs-chauffe-eau", // charge LOGIQUE (plus un thingId unique)
"label": "Chauffe-eau",
"adapter": "relay-router",
"mode": "fixed",
"priority": 2, // rang ; 1 = servi en premier
"enabled": true,
"needs": { "dailyDeadline": "06:00", "minEnergyWhPerDay": 4000 },
"relays": [ // ← la combinatoire vit ICI (routeur), pas chez l'optimiseur
{ "thingId": "{uuid-relais-500W}", "powerW": 500 },
{ "thingId": "{uuid-relais-1000W}", "powerW": 1000 },
{ "thingId": "{uuid-relais-2000W}", "powerW": 2000 }
],
"minOnS": 60,
"minOffS": 60
// powerLevels / maxPowerW : ABSENTS de la config — DÉRIVÉS de relays[] par le routeur.
}
```
### Cas continu (`adapter: "etmvariableload"`, mode `dynamic`)
```jsonc
{
"id": "{uuid-thing-triac}", "label": "Routeur PV salon", "adapter": "etmvariableload",
"mode": "dynamic", "maxPowerW": 3000, "priority": 1, "enabled": true
}
```
Règles :
- **`relays[]` ne franchit jamais la frontière** : il vit dans le routeur. L'optimiseur ne reçoit
que `powerLevels`/`maxPowerW` (watts dérivés) via le contexte.
- `relays[].thingId` = ThingId du thing `power`. **Choix par PICKER côté app** (voir §10) — le
picker est **UI-only** et ne fait qu'aider la saisie : il résout *nom du relais → ThingId* et
écrit des ThingIds dans `relays[]`. **Aucun champ supplémentaire au contrat.**
- `minOnS`/`minOffS` : verrous anti-rebond (protection relais / compresseur), **par charge**.
- `enabled:false` → rôle déclaré mais **exclu** de l'arbitrage (aucune action, aucun adaptateur
construit).
---
## 5. Règle d'arrondi (optimiseur) — inchangée
À chaque cycle, pour une charge dont c'est le tour dans la priorité :
- **Fixed** : `level = max( l ∈ powerLevels | l ≤ budget )` puis `Setpoint(level)`. L'optimiseur
connaît la granularité **dérivée** → pas de sur-allocation, **zéro cycle de retard**. (Le routeur
traduit ensuite `level` → relais.)
- **Dynamic** : `Setpoint( clamp(budget, 0, maxPowerW) )`.
- **Résidu** : lire `currentPowerW` **de début de cycle** (télémétrie de l'exécuteur — PAS de
relecture post-setpoint : invariant 8). Le résidu `budget currentPowerW` repart vers la
charge suivante de la priorité **du même cycle**.
**Déclaré vs réel :** `powerLevels`/`maxPowerW` (dérivés des relais) = référence de planification.
`currentPowerW` = juge runtime. Si le réel plafonne durablement sous le déclaré → erreur de
câblage/déclaration, l'installateur corrige. L'optimiseur **n'écrase jamais** la déclaration.
---
## 6. `LoadAction`**deux** kinds (inchangé)
| Kind | Pour | Porte |
|---|---|---|
| `Setpoint` | EV / ECS / routeur PV (via `RelayRouter` **ou** `EtmVariableLoadAdapter`) | `powerW` |
| *état SG-Ready* | PAC (interface à états, §8) | `state` (14) |
| `Constraint` | batterie (déféré 3f) | conservé |
L'index de combinaison de relais **n'existe pas** au niveau `LoadAction` : il est **privé au
routeur**. Le kind `Stage` reste supprimé (rév. 2). `reason` reste porté par chaque `LoadAction`.
---
## 7. Expo du `reason` — tableau additif `loadActions[]` (inchangé)
Dans `GetChargingSchedules` / `ChargingSchedulesChanged` :
```jsonc
"loadActions": [
{ "loadId": "ecs-chauffe-eau", "setpointW": 1500, "currentPowerW": 1480, "reason": "Surplus PV 1.6 kW — ECS rang 2" },
{ "loadId": "uuid-pac", "state": 3, "reason": "Surplus PV — PAC en état 3" }
]
```
`setpointW`/`currentPowerW` optionnels (absents pour une charge à états) ; `loadId` + `reason`
**toujours** présents. C'est le canal de la **decision card** de l'app.
---
## 8. PAC SG-Ready — modèle à états (conservé, inchangé)
Hors charge en watts. `SgReadyAdapter` (états 14, combos K1/K2, hystérésis, `minStateHold`)
n'est pas réécrit. L'arbitre lui envoie un **état**, pas un setpoint W. Repli L2 : état 2 (normal,
mains off), jamais état 1 (blocage).
---
## 9. Sécurité — repli L2 (rév. 3)
- **Watchdog L2** (`evaluateMeterFreshness`, 90 s, `applyDegradedMode`) inchangé. En dégradé :
- charges en watts (`RelayRouter` **et** `EtmVariableLoadAdapter`) → `Setpoint(0)` `force=true`.
Pour le `RelayRouter`, ça coupe **tous** les relais (`force` bypasse `minOn/minOff`).
- PAC → état 2.
- Ordre `update()` (sécurité avant planif) intact. `enabled:false` → exclu de l'arbitrage.
---
## 10. IMPACT APP — écran « Configurer charge pilotée » (⚠️ lire avant de coder l'UI)
**Ce qui change pour `etm-powersync-app` en rév. 3.** L'agent app doit lire ceci **avant** de
toucher à l'écran Configurer, sinon il code contre une frontière périmée (rév. 2).
- **AVANT (rév. 2)** : configurer une ECS = choisir **1 thing `etmvariableload`** + saisir des
`powerLevels`.
- **APRÈS (rév. 3)** : configurer une ECS = **ajouter une liste de relais** (things `power`) avec,
pour chacun, sa **puissance (W)**, + les verrous **`minOnS`/`minOffS`**. Plus de « 1 thing
etmvariableload » pour le cas relais.
- **Picker relais (UI-only)** : l'app présente les things implémentant l'interface `power`
(via l'API d'intégration nymea, équivalent `findConfiguredThings("power")`) pour que
l'installateur **choisisse ses relais par NOM** (taper un UUID au doigt = erreur garantie).
Le picker **résout nom → ThingId** et écrit des ThingIds dans `relays[]`. Il **n'ajoute aucun
champ au contrat** (comme l'assistant résistances).
- **Assistant résistances (UI-only, conservé)** : à partir des relais et de leurs puissances,
calcule et **affiche** les `powerLevels` atteignables (combinaisons) — pour que l'installateur
voie ce qu'il obtient. Ces `powerLevels` ne sont **pas** envoyés (dérivés côté plugin).
- **`SetLoadConfig`** reçoit donc `relays[]` (+ `minOnS`/`minOffS`) pour une charge relais ; le cas
continu (triac) garde un `thingId` + `maxPowerW`. Voir §4.
---
## 11. Dépendances liées (côté plugin)
- Handler `nymeaenergyjsonhandler` : `Get/SetLoadConfig` + persistance (fait ; schéma étendu rév. 3).
- `loadActions[]` dans `GetChargingSchedules` / `ChargingSchedulesChanged` (fait).
- Arbitre : construit `RelayRouter` (si `relays[]`) **ou** `EtmVariableLoadAdapter` (si continu)
depuis `LoadConfig` ; repli L2 unifié sur les exécuteurs en watts.
---
## Changelog
- **rév. 1** : `etmvariableload` interface unique, watts, un seul kind d'action.
- **rév. 2** : PAC SG-Ready **exclue** (modèle à états séparé) ; `LoadAction` garde **deux** kinds
(`Setpoint` + état SG-Ready) ; suppression du kind `Stage`.
- **rév. 3** : **frontière déplacée** entre optimiseur (watt-pur) et **routeur** (watts→relais,
experience-plugin) ; un relais = thing `power` nu ; `LoadConfig` du cas ECS devient une **liste
de relais** (`relays[]` + `minOnS`/`minOffS`) ; `powerLevels` **dérivés** ; cas continu
(`EtmVariableLoadAdapter`) inchangé. **Impact app §10.**

View File

@ -1,209 +0,0 @@
# SAFETY.md — Modèle de sécurité ETM PowerSync Energy
Décision Patrick Schurig, validée en session 2026-06-08. Ce document est normatif.
## Principes
Cinq couches indépendantes + signalisation locale optionnelle. Une couche supérieure
peut tomber sans impacter les couches inférieures. Jamais de contournement logiciel
d'une couche matérielle.
---
## L0 — Disjoncteur (matériel)
**Protège contre** : surintensité physique, défaut d'isolement.
**Responsable** : installateur électricien, norme C15-100.
**ETM ne touche pas à cette couche.** Elle fonctionne que nymead soit vivant ou non.
---
## L1 — Failsafe natif des bornes EV et charges pilotées
**Protège contre** : perte de communication borne ↔ nymead.
**Ne couvre PAS** : perte de communication compteur ↔ nymead (c'est le rôle de L2).
**Responsable** : configuration installateur sur chaque borne/appareil.
**Checklist ETM à la mise en service** :
- Borne EV : configurer le timeout de communication → repli sur courant minimum
défini par l'installateur (jamais 0 A si le VE est branché, jamais maximum abonnement).
- Relais ECS : désactivation si absence de commande > N minutes (selon chauffe-eau).
- SG-Ready PAC : état 1 (normal, non piloté) si silence > N minutes.
Cette configuration est documentée dans la checklist de déploiement
(`etm-powersync-deploy`), pas ici.
---
## L2 — Watchdog fraîcheur compteur (à implémenter en phase 3c)
**Protège contre** : compteur hors ligne, plugin gelé, nymead bloqué.
**Responsable** : code ETM dans `EnergyArbitrator`.
### Comportement
Un `QTimer` (pas un signal `meterChanged`) tourne en permanence avec une période de 30 s.
À chaque tick : si `QDateTime::currentDateTime() - m_lastMeterUpdate > 90 s`
**mode dégradé** déclenché.
**Choix de conception :** `QTimer` et non signal `powerBalanceChanged`, car si le
compteur est muet, le signal ne fire plus — le watchdog doit rester actif précisément
dans ce cas.
### Mode dégradé — consignes de repli (**Variante B — décision Patrick**)
À la **transition** vers le mode dégradé, les charges pilotées reçoivent une consigne de
repli **conservatrice** : le repli n'INITIE rien, il borne ce qui tourne déjà.
| Charge | Consigne de repli |
|--------|------------------|
| EV **en charge** | Clamp au courant minimum borne (`maxChargingCurrentMinValue`) |
| EV branché mais **pas en charge** | Inchangé — reste off (off possiblement volontaire : HC/spot à venir) |
| EV débranché | Aucune action |
| ECS | Relais coupé (palier 0, `force=true`) |
| SG-Ready PAC | État **2** (normal — mains off, la PAC chauffe selon son thermostat). JAMAIS état 1 (blocage) : bloquer une PAC sous compteur muet = maison qui ne chauffe plus sans raison visible. |
| Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) |
« Maintenu » ≠ « démarré » : le mode dégradé ne force jamais l'activation d'une charge.
La garantie *"jamais 0 A si le VE est branché"* appartient au **failsafe L1 de la borne**
(timeout communication → courant minimum installateur), pas au repli logiciel.
Chaque consigne porte un `decisionReason` explicite :
`"Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"`.
**Suspension de la planification** : tant que le mode dégradé est actif, `update()`
exécute la sécurité L4 (position 3, intouchable) puis **retourne immédiatement** — ni
`getPlan()` ni dispatch. Sinon `update()` replanifierait sur le **cache** d'un compteur
mort et rallumerait les charges que le watchdog vient de couper, que le tick suivant
recouperait 30 s plus tard (oscillation). Le repli est donc appliqué **une seule fois à
la transition** ; les consignes tiennent jusqu'au retour du compteur.
**Risque accepté** : ~1,4 kW de tirage EV non supervisé pour un VE déjà en charge au
moment de la bascule (minimum borne typique ≈ 6 A × 230 V × 1 phase). Ce tirage est
atténué par L0 (disjoncteur) et L1 (failsafe borne). Le client est informé (voir
Notification ci-dessous).
**Limite** : la notification part du contrôleur dégradé lui-même (best-effort). L'alerte
indépendante de la défaillance du contrôleur est le rôle de L3 (watchdog systemd).
### Notification client (dans ce repo)
- La notification JSON-RPC `NymeaEnergy.ChargingSchedulesChanged` porte un champ additif
`degradedMode` (bool). Elle est émise aux **transitions** du mode dégradé (entrée/sortie),
en plus des recalculs de planning. Voir `INTERFACE.md`.
- L'application affiche : *"Supervision compteur perdue — charge EV maintenue au minimum"*.
### Limite — détection par fraîcheur uniquement
Le watchdog L2 détecte l'**absence de mise à jour** du compteur (plus de signal
`powerBalanceChanged` depuis > 90 s), pas une **valeur figée**. Un compteur qui continue
d'émettre une valeur strictement constante (capteur bloqué mais lien vivant) n'est **pas**
détecté par cette couche — `m_lastMeterUpdate` reste frais. Détecter une valeur figée
(variance nulle sur fenêtre) est hors scope L2 ; le cas est couvert au niveau matériel/L0
et par la supervision externe.
### Signalisation locale (zéro cloud)
ETM PowerSync est **100 % autonome, zéro cloud** : aucune alerte ne sort vers un service
distant (ni n8n, ni mail, ni push tiers). Le système est conçu pour fonctionner **sans
internet** (argument produit : autoconsommation, local-first). Le `degradedMode` est
signalé par deux canaux strictement locaux :
- **Notification nymea in-app** (déjà implémentée : champ `degradedMode`) — canal principal
vers le client connecté à l'application.
- **Signal sonore local optionnel** (buzzer GPIO ou canal relais) piloté par une **Règle
nymea** déclenchée sur `degradedMode` — pour le client sur place, sans application.
Aucun code buzzer dans ce repo : le moteur **expose** l'état, la signalisation est une
Thing nymea + une règle (configuration d'installation, cf. `## Signalisation locale`).
**Aucun canal sortant réseau.** Voir l'invariant « ZÉRO cloud » dans `AGENTS.md`.
### Sortie du mode dégradé
Dès que le compteur fournit une nouvelle mesure (`m_lastMeterUpdate` remis à jour)
→ reprise normale au cycle suivant, `degradedMode: false`.
### Scénario de simulation obligatoire (DoD 3c)
Scénario `testMeterSilentFallback` dans `tests/auto/simulation/` :
1. Charger branché, surplus PV → charge en cours.
2. Geler les updates du compteur (mock).
3. Vérifier qu'après 90 s simulés, les consignes de repli sont émises avec le bon `reason`
et que `degradedMode` est à `true`.
4. Dégeler le compteur → vérifier la reprise normale et `degradedMode: false`.
---
## L3 — Watchdog systemd sur nymead
**Protège contre** : crash de nymead, deadlock process, défaillance de L2.
**Responsable** : `etm-powersync-deploy` (hors scope de ce repo).
Nymead enregistre `sd_notify(WATCHDOG=1)` ; systemd le relance si absent > timeout.
À la reprise, L1 garantit la sécurité matérielle pendant le redémarrage.
---
## L4 — Logique signal-driven existante (amont)
**Protège contre** : surcharge transitoire sur phases.
**Responsable** : `SmartChargingManager::verifyOverloadProtection()`.
### Double déclenchement (code amont vérifié)
`verifyOverloadProtection()` est déclenchée par **deux mécanismes distincts** :
1. **Signal temps réel** (`smartchargingmanager.cpp` ligne 127) :
```cpp
connect(m_energyManager, &EnergyManager::powerBalanceChanged, this, [this]() {
verifyOverloadProtection(QDateTime::currentDateTime());
});
```
C'est le mécanisme **principal** : déclenché à chaque nouveau bilan de puissance
émis par le compteur, sans attendre le prochain cycle `update()`.
2. **Appel cyclique** (`smartchargingmanager.cpp` ligne 313, dans `update()`) :
```cpp
verifyOverloadProtection(currentDateTime); // position 3 du cycle
```
Filet périodique — garantit une vérification même si le signal précède la mise à
jour des états internes.
Note : en mode simulation, `simulationCallUpdate()` (ligne 295-299) appelle `update()`
puis `verifyOverloadProtection()` une seconde fois — double appel intentionnel dans
les tests, pas dans la production.
### Règle de code ETM
`EnergyArbitrator::update()` appelle `verifyOverloadProtection()` en **position 3**,
identique à l'amont. INTERDIT de déplacer cet appel ou de le conditionner.
La connexion signal est héritée via le constructeur parent — aucune reconnexion ETM.
---
## Signalisation locale (optionnelle — hors de ce repo)
**Protège contre** : alarme silencieuse non détectée, exigeant une action humaine immédiate.
**Ce plugin** émet les événements (`degradedMode`, alarmes overload, etc.).
**La signalisation** est une Thing nymea (buzzer GPIO ou canal relais) + une **Règle**
déclenchée sur ces événements — configuration installation, documentée dans
`etm-powersync-deploy`.
Aucun code buzzer/relais dans ce repo. Principe : le moteur émet, la configuration
d'installation décide quoi signaler.
**Zéro cloud** : toute la signalisation est locale (notification nymea in-app +
signalisation physique). Aucun appel réseau sortant vers un service distant — le système
fonctionne sans internet. Invariant gravé dans `AGENTS.md`.
---
## Correspondance couches / scénarios de défaillance
| Défaillance | L0 | L1 | L2 | L3 | L4 | Signal local |
|-------------|----|----|----|----|-----|------|
| Surintensité réseau | ✅ | ✅ | — | — | ✅ | optionnel |
| Borne perd le réseau IP | — | ✅ | — | — | — | — |
| Compteur hors ligne > 90 s | ✅ | — | ✅ | — | — | optionnel |
| Crash nymead | — | ✅ | — | ✅ | — | — |
| Bug dans `update()` | — | ✅ | ✅ | ✅ | ✅ | — |
| Optimiseur externe mort | — | — | — | — | — | repli rule-based (AGENTS §6) |

View File

@ -1,358 +0,0 @@
# TEST_TERRAIN.md — Procédure de test Palier 1 (nymea-dev arm64)
Test terrain du moteur **etm-powersync-energy-plugin-etm** sur banc : compteur **mock
forçable** (puissance injectée par HTTP) + **relais GPIO réels** (état vérifié via
JSON-RPC nymead ou multimètre NO-COM).
14 tests (T1T14). Chaque test : **inject → log attendu → vérif relais → case ✓/✗**.
> Convention puissance compteur : `currentPower < 0` = **export** (surplus PV) ; `> 0` = **import**.
> Le moteur calcule un surplus net SIGNÉ `(exportW importW)` qui cascade par priorité.
---
## §0 — Pré-vol (à remplir SUR LA BOX)
| Élément | Valeur | Source |
|---|---|---|
| IP / hostname | `192.168.1.75` — hostname `hems`, user `etm` (sudo) | briefing terrain |
| Port JSON-RPC | `nymeas://hems:2222` (TCP+TLS) · `wss://hems:4444` | connu |
| Auth requise ? | `__________` | **[À LIRE SUR LA BOX]** `JSONRPC.Hello` → champ `authenticationRequired` |
| Token (si auth) | `__________` | `Users.Authenticate {username,password,deviceName}``token` |
| ThingClassId compteur mock | `2721a051-6e12-471a-baba-21d87c4cebc9` | connu (energymocks) |
| ThingId compteur mock | `__________` | **[À LIRE]** après `Integrations.AddThing`, ou `Integrations.GetThings` |
| Port HTTP compteur mock | `26655` | connu |
| ThingId R500 (Relay1 C1 BCM5, pin 29) | `8538782f-2c8c-4a30-bfce-c8140f791c9b` | briefing terrain |
| ThingId R1000 (Relay2 C2 BCM6, pin 31) | `2ebe6bef-829b-4695-9d11-ddb4c16c5448` | briefing terrain |
| ThingId R2000 (Relay3 C3 BCM13, pin 33) | `b033b212-1adb-4df0-ba2b-8fa477de52a2` | briefing terrain |
| ThingId K1 (Relay4 C4 BCM16, pin 36) | `beaf92e1-aedc-4b84-9ce4-e423648638cc` | briefing terrain |
| ThingId K2 (Relay5 C5 BCM19, pin 35) | `bf236e64-5ae7-4bf1-82a6-4ede03de75a6` | briefing terrain |
| gpiochip + BCM offsets | `gpiochip0` ; BCM R500=5, R1000=6, R2000=13, K1=16, K2=19 | vérifiés — **`gpioget` valide uniquement nymead ARRÊTÉ** |
| Conteneur build cross-arm64 | `__________` | **[À LIRE DANS `etm-powersync-deploy`/DEPLOY.md]** |
> `[À LIRE SUR LA BOX]` = dépend du déploiement, pas inventé ici. Tout le reste est figé.
---
## §1 — Déploiement (option a : build cross-arm64 → scp → dpkg)
### a. Build cross-arm64 (depuis le poste dev)
```bash
# Dans le conteneur de build cross-arm64 (cf. DEPLOY.md du repo etm-powersync-deploy).
# Produit le .deb arm64 : nymea-energy-plugin-nymea_<ver>_arm64.deb
# (TARGET inchangé = libnymea_energypluginnymea.so, drop-in remplaçant l'amont).
```
> Le nom de paquet/TARGET est **inchangé** (décision Phase 1) → le `.deb` ETM **remplace**
> le plugin énergie amont. Un seul plugin énergie chargé.
### b. Déploiement sur la box
```bash
BOX=hems
scp nymea-energy-plugin-nymea_*_arm64.deb etm@$BOX:/tmp/
ssh etm@$BOX 'sudo dpkg -i /tmp/nymea-energy-plugin-nymea_*_arm64.deb && sudo systemctl restart nymead'
ssh etm@$BOX 'sudo journalctl -u nymead -f' # suivre les logs
```
> `.so` installé dans `/usr/lib/<multiarch>/nymea/energy/libnymea_energypluginnymea.so`.
### c. Activer le logging `[Arbitre]` (SINON aucune trace de décision)
La catégorie est exactement **`NymeaEnergy`** (`NYMEA_LOGGING_CATEGORY(dcNymeaEnergy, "NymeaEnergy")`).
Méthode robuste — drop-in systemd (Qt logging rules) :
```bash
ssh etm@$BOX 'sudo mkdir -p /etc/systemd/system/nymead.service.d && \
printf "[Service]\nEnvironment=QT_LOGGING_RULES=NymeaEnergy.debug=true\n" | \
sudo tee /etc/systemd/system/nymead.service.d/etm-logging.conf >/dev/null && \
sudo systemctl daemon-reload && sudo systemctl restart nymead'
```
> Alternative selon la version : `nymead --logging <règles>` ou la section logging de
> `/etc/nymea/nymead.conf`. **[VÉRIFIER `nymead --help` SUR LA BOX]** si le drop-in ne suffit pas.
> Les lignes attendues commencent par `[Arbitre]` et `[EcsRelayAdapter]`/`[SgReadyAdapter]`.
### d. Déclarer les adaptateurs de test — LA VRAIE MÉTHODE
⚠️ **Il n'existe pas encore de config runtime des adaptateurs** (déféré : couche « config
priorités »). En production, `energypluginnymea.cpp::init()` crée l'`EnergyArbitrator` mais
**n'enregistre aucun adaptateur** ECS/SG-Ready. La déclaration se fait donc par un **bloc de
code** ajouté à `energypluginnymea.cpp` (juste après la création de `chargingManager`,
ligne ~54), recompilé dans le `.deb` de test.
Workflow : déployer une 1re fois → `Integrations.AddThing` le compteur mock + les relais GPIO
→ relever leurs ThingId (`GetThings`) → coller le bloc ci-dessous avec ces ThingId → rebuild → redéployer.
```cpp
// energypluginnymea.cpp, init(), APRÈS la ligne :
// EnergyArbitrator *chargingManager = new EnergyArbitrator(...);
#ifdef ETM_ARBITRATOR
{
ThingManager *tm = thingManager();
// --- ECS 3 relais (8 niveaux binaires) ---
const QString R500 = "{8538782f-2c8c-4a30-bfce-c8140f791c9b}"; // Relay1 C1 BCM5
const QString R1000 = "{2ebe6bef-829b-4695-9d11-ddb4c16c5448}"; // Relay2 C2 BCM6
const QString R2000 = "{b033b212-1adb-4df0-ba2b-8fa477de52a2}"; // Relay3 C3 BCM13
auto *ecs = new EcsRelayAdapter(
tm, "ecs-terrain", "ECS banc",
QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}),
QList<QList<QString>>({ {}, {R500}, {R1000}, {R500,R1000},
{R2000}, {R500,R2000}, {R1000,R2000}, {R500,R1000,R2000} }),
/*minOnS*/ 60, /*minOffS*/ 60, /*priority*/ 1, chargingManager);
chargingManager->registerEcsAdapter(ecs);
// --- SG-Ready (2 bits K1/K2) ---
const QString K1 = "{beaf92e1-aedc-4b84-9ce4-e423648638cc}"; // Relay4 C4 BCM16
const QString K2 = "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}"; // Relay5 C5 BCM19
auto *pac = new SgReadyAdapter(
tm, "pac-terrain", "PAC banc",
QHash<int,QList<QString>>({ {1,{K1}}, {2,{}}, {3,{K2}}, {4,{K1,K2}} }),
QHash<int,double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }),
/*minStateHoldS*/ 300, /*priority*/ 2, chargingManager);
chargingManager->registerSgReadyAdapter(pac);
}
#endif
```
> Inclure `#include "etm/adapters/ecsrelayadapter.h"` et `etm/adapters/sgreadyadapter.h`.
> Le **root meter** = le compteur mock : le déclarer via l'expérience énergie nymea
> (`Energy.SetRootMeter` / config) avec le ThingId du compteur mock.
> Pour l'ECS simple (T1/T2), utiliser un seul relais : `stages {0,2000}`, `mapping {{},{R2000}}`.
---
## Helpers bash (poste dev)
```bash
BOX=hems; MPORT=26655
# ThingIds relais (banc hems — pré-vérifiés, cf. §0)
TID_R500="8538782f-2c8c-4a30-bfce-c8140f791c9b"
TID_R1000="2ebe6bef-829b-4695-9d11-ddb4c16c5448"
TID_R2000="b033b212-1adb-4df0-ba2b-8fa477de52a2"
TID_K1="beaf92e1-aedc-4b84-9ce4-e423648638cc"
TID_K2="bf236e64-5ae7-4bf1-82a6-4ede03de75a6"
# Injecter une puissance compteur (W). Négatif = export (surplus PV), positif = import.
inject(){ curl -s "http://$BOX:$MPORT/setstates?connected=true&currentPowerPhaseA=$(($1/3))&currentPowerPhaseB=$(($1/3))&currentPowerPhaseC=$(($1/3))" >/dev/null; echo "compteur = $1 W"; }
# Compteur MUET : on cesse d'injecter (>90 s) → watchdog L2 bascule (QTimer 30 s, seuil 90 s).
mute(){ echo "NE PLUS injecter pendant >90 s…"; sleep 95; }
# Suivre les décisions de l'arbitre.
logs(){ ssh etm@$BOX "sudo journalctl -u nymead -f | grep -E 'Arbitre|EcsRelay|SgReady'"; }
```
```bash
# relay <ThingId> → 1 (ON) | 0 (OFF) via JSON-RPC local nymead.
# Protocole complet : Hello → auth si requise → GetThingClasses (résolution stateTypeId
# 'power') → GetThings. Framing : boucle par id — ignore les notifications push nymea.
# gpioget inutilisable nymead actif (libgpiod v2 busy). Filet : multimètre NO-COM nymead arrêté.
# Variables optionnelles : NYMEA_USER (défaut admin) NYMEA_PASS (défaut vide).
# ACCEPTANCE avant T1 : forcer un relais ON via l'app nymea, vérifier relay $TID_Rxx → 1 ;
# forcer OFF → 0. Tant que ce test isolé ne passe pas, la colonne "vérif" de T1T14 est aveugle.
relay(){
local tid="$1"
local nu="${NYMEA_USER:-admin}" np="${NYMEA_PASS:-}"
ssh etm@$BOX "python3 - '$nu' '$np'" << GETSTATE
import ssl, socket, json, sys
user, pw = sys.argv[1], sys.argv[2]
ctx = ssl.create_default_context()
ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
buf = b''
def call(s, rid, method, params=None, token=None):
global buf
req = {'id': rid, 'method': method}
if params: req['params'] = params
if token: req['token'] = token
s.sendall((json.dumps(req) + '\n').encode())
while True:
buf += s.recv(4096)
lines = buf.split(b'\n'); buf = lines[-1]
for ln in lines[:-1]:
ln = ln.strip()
if not ln: continue
try:
m = json.loads(ln)
if m.get('id') == rid: return m
except: pass
with socket.create_connection(('localhost', 2222), timeout=10) as raw, \
ctx.wrap_socket(raw) as s:
hello = call(s, 1, 'JSONRPC.Hello')
token = None
if hello.get('params', {}).get('authenticationRequired', False):
r = call(s, 2, 'Users.Authenticate',
{'username': user, 'password': pw, 'deviceName': 'relay-chk'})
p = r.get('params', {})
if not p.get('success', True) or 'token' not in p:
sys.exit('AUTH_FAILED — définir NYMEA_USER / NYMEA_PASS')
token = p['token']
# Résoudre stateTypeId de 'power' pour chaque ThingClass (les states dans GetThings
# ne portent pas le nom, seulement le stateTypeId — cf. nymea JSON-RPC spec).
tc = call(s, 3, 'Integrations.GetThingClasses', token=token)
power_stid = {}
for c in tc.get('params', {}).get('thingClasses', []):
for st in c.get('stateTypes', []):
if st.get('name') == 'power':
power_stid[c['id']] = st['id']
resp = call(s, 4, 'Integrations.GetThings', token=token)
things = resp.get('params', {}).get('things', [])
t = next((x for x in things if x['id'] == '${tid}'), None)
if not t: sys.exit('THING_NOT_FOUND: ${tid}')
stid = power_stid.get(t.get('thingClassId', ''), '')
state = next((st for st in t.get('states', []) if st.get('stateTypeId') == stid), None)
print(1 if state and state.get('value') else 0)
GETSTATE
}
```
> Le compteur mock pose `currentPower = somme des 3 phases`. `RootMeter::currentPower()` le relit.
---
## §2 — ECS simple (1 relais, paliers {0, 2000})
### T1 — Montée sur surplus
- **inject** : `inject -2500`
- **log attendu** : `[Arbitre] … Surplus PV … ECS palier 1` puis `[EcsRelayAdapter] … → stage 1`
- **vérif** : `relay $TID_R2000`**1** (ON)
- ✓ / ✗ : `____`
### T2 — Délestage (import)
- **inject** : `inject 1000` (import → surplus net négatif)
- **log attendu** : `Surplus insuffisant … ECS éteint` ; `→ stage 0`
- **vérif** : `relay $TID_R2000`**0** (OFF)
- ✓ / ✗ : `____`
---
## §3 — ECS 3 relais (8 niveaux binaires R500/R1000/R2000)
### T3 — Cascade montante
- **inject** : `inject -1700` (budget 1700 → palier **1500**)
- **log** : `ECS palier 3 (1500 W)`
- **vérif** : `relay $TID_R500`→**1**, `relay $TID_R1000`→**1**, `relay $TID_R2000`→**0**
- ✓ / ✗ : `____`
### T4 — Transition NON-CASCADÉE 1500 → 2000 (le test clé)
- **contexte** : on part de T3 (palier 1500, l'ECS mesure 1500 W).
- **inject** : `inject -700` (budget = 700 + 1500 recrédit = 2200 → palier **2000**)
- **log** : `ECS palier 4 (2000 W)` ; commutation **off-before-on** (R500/R1000 coupés avant R2000)
- **vérif** : `relay $TID_R500`→**0**, `relay $TID_R1000`→**0**, `relay $TID_R2000`→**1** ← *set final R2000 SEUL, pas un état parasite durable*
- ✓ / ✗ : `____`
### T5 — Protection minOn (anti court-cycling)
- **contexte** : ECS vient de commuter (< minOn 60 s).
- **inject** : `inject 1200` (import → le budget voudrait éteindre)
- **log attendu** : `Verrou minOn — … maintenu palier …`
- **vérif** : relais **inchangés** (maintien). Attendre > 60 s puis re-`inject 1200` → délestage.
- ✓ / ✗ : `____`
### T6 — Délestage complet
- **inject** : `inject 2000` (import franc, > minOn écoulé)
- **vérif** : `relay $TID_R500`→**0**, `relay $TID_R1000`→**0**, `relay $TID_R2000`→**0**
- ✓ / ✗ : `____`
---
## §3.5 — Pré-vol SG-Ready (**à cocher avant T7, nymead ARRÊTÉ**)
> ⚠️ La polarité K1/K2 est le **seul point non vérifié proprement** de toute la chaîne.
> Le test initial a échoué sur `Device or resource busy` (nymead tient les lignes GPIO via
> libgpiod v2). Cette vérification doit se faire **nymead arrêté**, au multimètre, avant
> toute mise sous tension côté PAC.
```bash
ssh etm@$BOX 'sudo systemctl stop nymead'
ssh etm@$BOX 'gpioget -c gpiochip0 16 19' # attendu : 0 0 (repos LOW BCM16/BCM19)
```
- [ ] `gpioget` répond **0 0** — K1 (BCM16) et K2 (BCM19) au repos LOW. ✓
- [ ] **K1 repos = circuit ouvert** : multimètre en continuité NO-COM sur bornes K1 → **circuit ouvert**
= PAC interprète 0:0 = **état 2 « normal »** (jamais 1:0 = blocage au boot). ✓
- [ ] **K2 repos = circuit ouvert** (idem). ✓
- [ ] **Fail-safe boot confirmé** : nymead démarre K1=K2=0 (repos LOW, NO/NC → circuit ouvert → PAC = normal). ✓
- [ ] `sudo systemctl start nymead` avant T7.
---
## §4 — SG-Ready (PAC, 2 bits K1/K2 ; P3=1500, P4=3000)
> **Seuils code** (`rulebasedscheduler.cpp`, `kForceMargin=1.2`, P3=1500, P4=3000) :
> - État 3 si budgetW ≥ 1500 ; état 4 si budgetW ≥ P4×1,2 = **3600** ; zone morte état 4 si budgetW ≥ 3000.
> - `budgetW = exportW_brut + allocatedNow` ; allocatedNow = 0 (état 2), 1500 (état 3), 3000 (état 4).
> - Depuis état 2 (sans recrédit) : min. inject **3600** pour atteindre état 4 directement.
> - Depuis état 3 (recrédit 1500) : min. inject **2100** pour atteindre état 4.
### T7 — Montée d'états 2 → 3 → 4
- `inject -1000` → état **2** (budget 1000 < 1500, K1=0,K2=0).
- `inject -2000` → état **3** (budget 2000 ≥ 1500, K2=1). Attendre > minStateHold (300 s).
- `inject -2500` → état **4** (budget = 2500 + 1500 recrédit = **4000 ≥ 3600**, K1=1,K2=1).
- **log** : `… recommandée (état 3 …)` puis `… forcée (état 4 …)`
- **vérif** : état 3 → `relay $TID_K1`→**0**, `relay $TID_K2`→**1** ; état 4 → `relay $TID_K1`→**1**, `relay $TID_K2`→**1**
- ✓ / ✗ : `____`
### T8 — Atomicité (transitoire bénin)
- **contexte** : transition 2→4 (00→11) commute K1 ET K2.
- **vérif** : set **final** `relay $TID_K1`→**1**, `relay $TID_K2`→**1** (état 4). Le transitoire
passe par K2 d'abord (01=reco), **jamais** 10=blocage — sub-ms, non observable, mais
**garanti code** (`transientHarm`). Confirmer simplement le set final correct.
- ✓ / ✗ : `____`
### T9 — Zone morte hystérésis (pas de bascule 4→3 sur fluctuation surplus)
- **contexte** : PAC en état 4 (K1=K2=1) depuis > minStateHold (300 s).
L'opérateur injecte le **surplus brut au compteur** — le moteur recrédite la puissance
allouée (3000 W déclarés) en interne ; ne pas l'ajouter à `inject`.
- Faire fluctuer le surplus : `inject -300` → reste **4** ; `inject -500` → reste **4** ;
`inject -100` → reste **4** ; `inject 0` (équilibré) → reste **4** (tout inject ≤ 0 tient l'état).
- **Sortie** : `inject 300` (import net) → **état 3**.
- **vérif** : `relay $TID_K1`→**1**, `relay $TID_K2`→**1** stable pendant la fluctuation ;
`relay $TID_K1`→**0** uniquement après `inject 300`.
- ✓ / ✗ : `____`
---
## §5 — Watchdog L2 (compteur muet)
### T10 — Compteur muet → mode dégradé
- **inject** `-2500` (ECS/PAC servis), puis **`mute`** (>90 s sans injection).
- **log attendu** : `[Arbitre] Compteur muet depuis … mode dégradé L2` ; ECS palier 0 ; PAC **état 2**.
- **vérif** : `relay $TID_R500`→**0**, `relay $TID_R1000`→**0**, `relay $TID_R2000`→**0** ;
`relay $TID_K1`→**0**, `relay $TID_K2`→**0** (état 2, **jamais** blocage).
- ✓ / ✗ : `____`
### T11 — Stabilité (faux surplus piège)
- **contexte** : toujours muet ; la **dernière** valeur injectée (-2500) reste « collée » au compteur.
- **vérif** : sur plusieurs minutes muettes, ECS **reste 0** malgré ce surplus stale
(planification suspendue — pas de replanif sur cache mort).
- ✓ / ✗ : `____`
### T12 — Reprise
- **inject** `-2500` (le compteur re-parle).
- **log attendu** : `Compteur de nouveau actif — sortie du mode dégradé` ; recalcul normal.
- **vérif** : ECS resuit le surplus (palier > 0). Pas de restauration d'ancienne consigne.
- ✓ / ✗ : `____`
---
## §6 — Interaction budget partagé
### T13 — Ordre de priorité ECS↔PAC inversable
- **contexte** : surplus moyen `inject -3000`, ECS palier 2400 vs PAC P3 1500.
- **ECS prio 1 / PAC prio 2** : ECS se sert (palier), PAC voit le reliquat (600) → **état 2**.
- **Inverser les priorités** (échanger `priority` dans le bloc §1.d, rebuild/redeploy) →
PAC se sert (état 3), ECS voit le reliquat (1500 < 2400) **palier 0**.
- **vérif** : le service s'inverse selon la priorité (preuve du waterfall unifié).
- ✓ / ✗ : `____`
---
## §7 — OPTIONNEL EV / V2C (si plugin prêt)
### T14 — Ordre étagé EV → ECS (beta : EV servi avant le waterfall)
- **contexte** : borne EV branchée + surplus moyen.
- **attendu** : l'EV est servi **en premier** (proxy amont, décision B), l'ECS voit le reliquat.
- **vérif** : courant EV puis palier ECS sur le reliquat. *(V2C = session dédiée, hors Palier 1.)*
- ✓ / ✗ : `____`
---
## Checklist sécurité (AVANT mise sous tension)
- [ ] **Disjoncteur L0** du banc **accessible** et identifié (coupure physique immédiate).
- [ ] Banc câblé hors tension ; sections/calibres relais conformes aux puissances (R2000 = 2000 W).
- [ ] **Polarité SG-Ready vérifiée** (§3.5 fait et coché) — seul point non vérifié avant cette session.
- [ ] Relais résistifs ECS : pas de charge inductive sur ces voies.
- [ ] Mapping BCM↔relais consigné (`gpioinfo gpiochip0`, nymead arrêté) avant tout test.
- [ ] L1 (failsafe bornes/relais) configuré si applicable ; L0 reste le filet ultime.
- [ ] Un opérateur à la main sur le disjoncteur pendant T1T6 (montées de puissance).

View File

@ -1,12 +1,3 @@
# Rend les headers du répertoire energyplugin/ accessibles aux consommateurs
# (simulation, tests) qui incluent ce .pri depuis un autre répertoire.
INCLUDEPATH += $$PWD
# [ETM] Activate ETM arbitrator — replaces SmartChargingManager::update() with EnergyArbitrator.
# Propagé à tous les consommateurs du .pri (plugin + simulation + tests).
# Uncommenter pour activer. Commité DÉSACTIVÉ jusqu'à preuve iso (3b-iv).
DEFINES += ETM_ARBITRATOR
greaterThan(QT_MAJOR_VERSION, 5) {
message("Building using Qt6 support")
CONFIG *= c++17
@ -46,8 +37,6 @@ HEADERS += \
$$PWD/types/smartchargingstate.h \
$$PWD/types/timeframe.h \
include($$PWD/etm/etm.pri)
SOURCES += \
$$PWD/energymanagerconfiguration.cpp \
$$PWD/energysettings.cpp \

View File

@ -28,14 +28,6 @@
#include "energymanagerconfiguration.h"
#include "spotmarket/spotmarketmanager.h"
// [ETM] BEGIN — EnergyArbitrator flip. Remove block to revert to upstream SmartChargingManager.
#ifdef ETM_ARBITRATOR
#include "etm/energyarbitrator.h"
#include "etm/adapters/sgreadyadapter.h"
#include "etm/config/loadconfigstore.h"
#endif
// [ETM] END
#include "plugininfo.h"
EnergyPluginNymea::EnergyPluginNymea(QObject *parent) : EnergyPlugin(parent)
@ -49,34 +41,8 @@ void EnergyPluginNymea::init()
EnergyManagerConfiguration *configuration = new EnergyManagerConfiguration(this);
QNetworkAccessManager *networkManager = new QNetworkAccessManager(this);
SpotMarketManager *spotMarketManager = new SpotMarketManager(networkManager, this);
LoadConfigStore *loadConfigStore = nullptr;
#ifdef ETM_ARBITRATOR
qCDebug(dcNymeaEnergy()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé.";
EnergyArbitrator *chargingManager = new EnergyArbitrator(energyManager(), thingManager(), spotMarketManager, configuration, this);
// T4 — charges pilotables etmvariableload (ECS/routeur) construites depuis la config
// persistée (remplace le registre en dur « 3g »). SetLoadConfig pilote ce store.
loadConfigStore = new LoadConfigStore(this);
chargingManager->setLoadConfigStore(loadConfigStore);
// §1.d — Banc hems : SG-Ready PAC (ThingIds briefing terrain) — PAC hors config (§8, gelée).
{
ThingManager *tm = thingManager();
const QString K1 = "{beaf92e1-aedc-4b84-9ce4-e423648638cc}";
const QString K2 = "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}";
auto *pac = new SgReadyAdapter(
tm, "pac-terrain", "PAC banc",
QHash<int,QList<QString>>({ {1,{K1}}, {2,{}}, {3,{K2}}, {4,{K1,K2}} }),
QHash<int,double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }),
300, 2, chargingManager);
chargingManager->registerSgReadyAdapter(pac);
}
#else
SmartChargingManager *chargingManager = new SmartChargingManager(energyManager(), thingManager(), spotMarketManager, configuration, this);
#endif
jsonRpcServer()->registerExperienceHandler(new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, loadConfigStore, this), 0, 8);
jsonRpcServer()->registerExperienceHandler(new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, this), 0, 8);
}

View File

@ -1,138 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "etmvariableloadadapter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <algorithm>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &powerLevels,
int maxPowerW,
int priority,
const LoadNeeds &needs,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_powerLevels(powerLevels)
, m_maxPowerW(maxPowerW)
, m_priority(priority)
, m_needs(needs)
{
// Re-tri par sécurité : l'UI émet trié, l'energymanager re-trie (contrat §6 cas limites).
std::sort(m_powerLevels.begin(), m_powerLevels.end());
// Contrat §2 : les paliers incluent 0. En *fixed* (liste non vide), 0 doit être présent.
Q_ASSERT(m_powerLevels.isEmpty() || m_powerLevels.first() == 0);
}
LoadDescriptor EtmVariableLoadAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("etmvariableload");
d.priority = m_priority;
d.declared.powerLevels = m_powerLevels;
d.declared.maxPowerW = m_maxPowerW;
d.needs = m_needs;
d.supportedKinds = { LoadAction::Setpoint };
return d;
}
LoadTelemetry EtmVariableLoadAdapter::telemetry() const
{
LoadTelemetry t;
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
t.available = (thing != nullptr);
t.currentPowerW = readCurrentPowerW(); // juge runtime (contrat §4)
t.lastActionAt = m_lastActionAt;
return t;
}
LoadContext EtmVariableLoadAdapter::toLoadContext(const QDateTime &now) const
{
Q_UNUSED(now) // aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing.
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("etmvariableload");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared;
ctx.telemetry.currentPowerW = readCurrentPowerW();
return ctx;
}
LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Setpoint)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
// Second filet (invariant ILoadAdapter) : borner la consigne au plafond physique.
// L'arrondi au powerLevels (mode *fixed*) est fait par le scheduler (contrat §4, T3).
const double setpointW = qBound(0.0, action.powerW, static_cast<double>(m_maxPowerW));
qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "→ setpoint" << qRound(setpointW) << "W"
<< (action.force ? "(force)" : "")
<< "|" << action.reason;
writeSetpoint(setpointW);
m_currentSetpointW = setpointW;
m_lastActionAt = now;
LoadAction applied = action;
applied.powerW = setpointW;
applied.estimatedPowerW = setpointW;
return applied;
}
// ---- privé ---------------------------------------------------------------
double EtmVariableLoadAdapter::readCurrentPowerW() const
{
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
if (!thing)
return 0.0;
if (thing->thingClass().stateTypes().findByName("currentPowerW").id().isNull())
return 0.0;
return thing->stateValue("currentPowerW").toDouble();
}
void EtmVariableLoadAdapter::writeSetpoint(double powerW)
{
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
if (!thing) {
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— thing non trouvé:" << m_id;
return;
}
StateType setpointStateType = thing->thingClass().stateTypes().findByName("powerSetpoint");
if (!setpointStateType.id().isNull()) {
Action setpointAction(setpointStateType.id(), thing->id(), Action::TriggeredByRule);
setpointAction.setParams(ParamList() << Param(setpointStateType.id(), powerW));
m_thingManager->executeAction(setpointAction);
} else {
thing->setStateValue("powerSetpoint", powerW); // repli mock
}
}

View File

@ -1,130 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QList>
#include <QString>
#include "iloadadapter.h"
class Thing;
class ThingManager;
/*!
* \brief Adaptateur charge à puissance pilotable interface \c etmvariableload (contrat
* INTERFACE_etmvariableload.md). Action \c kind:Setpoint en WATTS.
*
* Couvre les charges dont la consigne est une puissance : ECS multipalier, routeur PV /
* triac, résistance modulable. \b Pas la PAC SG-Ready (modèle à états, \c SgReadyAdapter).
*
* \par Frontière (contrat §1 / §5) non négociable
* L'energymanager raisonne UNIQUEMENT en watts. \b Aucune résistance, relais, combinaison
* ni index de palier matériel ne traverse cet adaptateur. La connaissance matérielle (quelle
* résistance, quel triac, sur quelle phase), l'anti-rebond (minOn/minOff) et l'équilibrage
* de phases vivent ENTIÈREMENT dans le \b thing qui implémente l'interface. Cet adaptateur
* se contente d'écrire une consigne \c powerSetpoint (W) et de relire \c currentPowerW.
*
* \par States nymea consommés sur le thing cible (\c m_id)
* - \c currentPowerW (read) : puissance réellement appliquée juge runtime (contrat §4).
* - \c powerSetpoint (write) : consigne demandée par l'energymanager.
* - \c maxPowerW / \c powerLevels (read) : déclarés par le thing pour le wizard ; la
* planification utilise la DÉCLARATION de config (passée au constructeur, contrat §4),
* pas ces states l'adaptateur n'écrase jamais la déclaration.
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant Seul le kind \c Setpoint est traité ; les autres kinds retournent sans effet.
* \invariant La consigne est écrêtée à \c [0, maxPowerW] avant envoi matériel (second filet
* après l'écrêtage de l'arbitre). L'arrondi au \c powerLevels (mode *fixed*) est la
* responsabilité du SCHEDULER (contrat §4), pas de l'adaptateur.
* \invariant **Temps = paramètre, jamais l'horloge** (cf. \c ILoadAdapter) : aucune logique
* temporelle ici (les verrous vivent dans le thing) \c now sert uniquement aux estampilles.
*
* \note T1 (squelette) : l'adaptateur consomme l'interface côté moteur. La déclaration de
* l'interface nymea \c etmvariableload et le driver thing relèvent d'une session device dédiée.
*/
class EtmVariableLoadAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre \p id en Thing.
* \param id ThingId (string) du thing implémentant \c etmvariableload, et
* identifiant logique de la charge.
* \param label Nom lisible (logs, app).
* \param powerLevels Paliers atteignables en W (DÉCLARÉS, triés croissants, \c 0 inclus)
* en mode *fixed* ; liste vide *dynamic* (modulation 0..maxPowerW).
* \param maxPowerW Plafond physique (W) contrat §2.
* \param priority Rang dans le waterfall (contrat §5 / OPTIMIZER_PROTOCOL §5) : valeur
* plus BASSE = servi en premier (rang 1 = premier servi).
* \param needs Besoins déclarés (échéances, énergie min) exposés au scheduler
* via \c descriptor().needs. Vide par défaut.
* \param parent Propriétaire Qt.
*/
explicit EtmVariableLoadAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &powerLevels,
int maxPowerW,
int priority,
const LoadNeeds &needs = LoadNeeds(),
QObject *parent = nullptr);
/*!
* \brief Description statique : adapter="etmvariableload", powerLevels, maxPowerW, priority.
* \return LoadDescriptor déclaratif (référence de planification, contrat §4).
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime. \c currentPowerW = state \c currentPowerW MESURÉ du thing
* (juge de quantification, contrat §4) ; \c available faux si le thing est absent.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 (adapter="etmvariableload").
* \param now Temps de cycle (\c ctx.timestamp) estampille uniquement ; aucune fenêtre de
* verrou côté moteur (l'anti-rebond vit dans le thing, contrat §2).
* \return LoadContext incluant declared (powerLevels/maxPowerW) et \c currentPowerW.
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Écrit la consigne \c powerSetpoint (W) sur le thing.
* \param action LoadAction de kind \c Setpoint ; consigne lue dans \c action.powerW. Autres
* kinds : retour sans effet.
* \param now Temps de cycle (\c ctx.timestamp) estampille \c m_lastActionAt.
* \return L'action après écrêtage matériel (\c powerW borné à [0, maxPowerW]).
*
* \invariant Si \c action.reason est vide retour sans effet (log warning).
* \invariant \c action.force == true (repli L2 : \c setPowerSetpoint(0)) est transmis tel
* quel ; le bypass anti-rebond est honoré par le thing, pas ici.
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing). */
double currentSetpointW() const { return m_currentSetpointW; }
private:
//! Vrai si aucun palier déclaré n'est fourni ⇒ modulation continue (contrat §2).
bool isDynamic() const { return m_powerLevels.isEmpty(); }
//! Écrit le state \c powerSetpoint (W) via executeAction (repli \c setStateValue pour mock).
void writeSetpoint(double powerW);
//! Lit le state \c currentPowerW du thing cible (0 si absent / non exposé).
double readCurrentPowerW() const;
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QList<int> m_powerLevels; //!< Paliers W déclarés (vide = dynamic).
int m_maxPowerW;
int m_priority;
LoadNeeds m_needs;
double m_currentSetpointW = 0; //!< Dernière consigne écrite (W).
QDateTime m_lastActionAt;
};

View File

@ -1,94 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "evadapter.h"
#include "../energyarbitrator.h"
#include "../../evcharger.h"
#include "../../types/chargingaction.h"
#include "plugininfo.h"
EvAdapter::EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent)
: QObject(parent)
, m_charger(evCharger)
, m_parent(parent)
{
}
LoadDescriptor EvAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_charger->thing()->id().toString();
d.label = m_charger->name();
d.adapter = QStringLiteral("evcharger");
d.priority = 100;
d.declared.minA = m_charger->maxChargingCurrentMinValue();
d.declared.maxA = m_charger->maxChargingCurrentMaxValue();
d.declared.phases = static_cast<int>(m_charger->phaseCount());
d.limits.chargingEnabledLockS = static_cast<int>(m_charger->chargingEnabledLockDuration());
d.limits.currentChangeLockS = static_cast<int>(m_charger->chargingCurrentLockDuration());
d.supportedKinds = { LoadAction::Setpoint };
return d;
}
LoadTelemetry EvAdapter::telemetry() const
{
LoadTelemetry t;
t.currentPowerW = m_charger->currentPower();
t.available = m_charger->available();
t.lastActionAt = m_lastActionAt;
return t;
}
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
{
Q_UNUSED(now) // L'EV n'a pas de verrou de palier (pas de waterfall ECS) — now inutilisé ici.
LoadContext ctx;
ctx.id = m_charger->thing()->id().toString();
ctx.adapter = QStringLiteral("evcharger");
ctx.label = m_charger->name();
ctx.declared = descriptor().declared;
ctx.limits = descriptor().limits;
ctx.telemetry.currentPowerW = m_charger->currentPower();
ctx.telemetry.pluggedIn = m_charger->pluggedIn();
ctx.telemetry.charging = m_charger->charging();
return ctx;
}
LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Setpoint)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[EvAdapter]" << m_charger->name()
<< "— LoadAction sans reason rejetée.";
return action;
}
const uint minA = m_charger->maxChargingCurrentMinValue();
const uint maxA = m_charger->maxChargingCurrentMaxValue();
const uint clampedA = static_cast<uint>(
qBound(static_cast<double>(minA), action.currentA, static_cast<double>(maxA)));
const uint phases = (m_charger->canSetPhaseCount() && action.phaseCount > 0)
? qBound(1u, action.phaseCount, m_charger->phaseCount())
: m_charger->phaseCount();
const auto issuer = (action.funding == LoadAction::Surplus)
? ChargingAction::ChargingActionIssuerSurplusCharging
: ChargingAction::ChargingActionIssuerTimeRequirement;
ChargingAction ca(action.chargingEnabled, clampedA, phases, issuer, false);
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
m_lastActionAt = now;
LoadAction applied = action;
applied.currentA = clampedA;
applied.phaseCount = phases;
return applied;
}

View File

@ -1,81 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include "iloadadapter.h"
class EvCharger;
class EnergyArbitrator;
/*!
* \brief Adaptateur pour une borne de recharge VE (interface evcharger nymea).
*
* Traduit les LoadAction(Setpoint) en appels matériels via
* EnergyArbitrator::doExecuteChargingAction() le seul chemin d'exécution.
*
* \invariant applyAction() rejette silencieusement toute LoadAction dont \c reason est vide.
* \invariant currentA est écrêté à [maxChargingCurrentMinValue, maxChargingCurrentMaxValue].
* \invariant phaseCount est écrêté selon canSetPhaseCount() et phaseCount() du EvCharger.
* \invariant Les kinds autres que Setpoint sont retournés sans effet.
*/
class EvAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param evCharger Borne VE à piloter (doit rester valide tant que l'adaptateur existe).
* \param parent EnergyArbitrator propriétaire utilisé pour l'exécution matérielle.
*/
explicit EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent);
/*!
* \brief Retourne la description statique de la charge.
* \return LoadDescriptor construit depuis les capacités actuelles du EvCharger.
* \note Recalculé à chaque appel depuis l'état nymea du Thing.
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Retourne la télémétrie runtime (puissance mesurée, disponibilité).
* \return LoadTelemetry avec currentPowerW, available et lastActionAt.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). Inutilisé ici : l'EV n'a pas de verrou
* de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter.
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique une consigne Setpoint sur la borne VE.
*
* **Inactif jusqu'à 3g** : non appelée par \c EnergyArbitrator::update() en beta.
* Le dispatch EV passe par \c adjustEvChargers() amont (hérité). Cette méthode
* sera câblée lors de la transplantation EV dans \c RuleBasedScheduler (phase 3g).
* \c descriptor() et \c telemetry() sont eux actifs dès maintenant pour le SurplusContext.
*
* \param action LoadAction de kind Setpoint. Autres kinds : retour sans effet.
* \param now Temps de cycle (\c ctx.timestamp) passé à \c doExecuteChargingAction()
* (locks anti-rebond de la borne). MÊME source que toLoadContext() (contrat ILoadAdapter).
* \return L'action après écrêtage matériel (currentA, phaseCount bornés).
*
* \invariant action.reason non vide requis log warning et retour sans effet sinon.
* \invariant currentA écrêté à [minValue, maxValue] avant envoi à executeChargingAction.
* \invariant phaseCount ajusté selon canSetPhaseCount() du EvCharger.
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief Borne VE sous-jacente (lecture). */
EvCharger *evCharger() const { return m_charger; }
private:
EvCharger *m_charger;
EnergyArbitrator *m_parent;
QDateTime m_lastActionAt;
};

View File

@ -1,75 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include "../types/loadaction.h"
#include "../types/loaddescriptor.h"
#include "../types/surpluscontext.h"
/*!
* \brief Vue runtime minimale exposée par un adaptateur à l'arbitre.
*/
struct LoadTelemetry {
double currentPowerW = 0; //!< Puissance mesurée (W).
bool available = true; //!< Faux si l'appareil nymea est absent ou en erreur.
QDateTime lastActionAt; //!< Dernier instant où applyAction() a produit un effet.
};
/*!
* \brief Interface pure des adaptateurs de charge.
*
* Les implémentations concrètes héritent de QObject + ILoadAdapter et déclarent leurs
* propres signaux (telemetryChanged, descriptorChanged).
*
* \invariant Les adaptateurs EXÉCUTENT, ils ne décident pas (AGENTS règle 2).
* \invariant applyAction() écrête les valeurs selon les limites matérielles réelles
* (second filet après l'écrêtage de l'arbitre).
* \invariant applyAction() avec \c reason vide doit être rejetée silencieusement.
* \invariant Les méthodes non-applyAction() retournent immédiatement (pas de I/O bloquant).
* \invariant **Temps = paramètre, jamais l'horloge.** Toute logique temporelle d'un
* adaptateur (verrous minOn/minOff, fenêtres, fraîcheur) utilise EXCLUSIVEMENT le
* \c now (= \c ctx.timestamp) reçu en paramètre de \c toLoadContext()/applyAction().
* JAMAIS \c QDateTime::currentDateTime(). C'est cette source unique, partagée avec le
* scheduler, qui rend impossible toute divergence décision/exécution et qui rend la
* logique injectable en simulation. Contrat pour tout futur adaptateur (SgReady, Battery).
*/
class ILoadAdapter {
public:
virtual ~ILoadAdapter() = default;
/*!
* \brief Description statique de la charge : capacités, limites, priorité, needs.
* \return LoadDescriptor construit depuis la configuration matérielle.
* \note Peut être rappelé à chaque cycle l'implémentation doit être légère.
*/
virtual LoadDescriptor descriptor() const = 0;
/*!
* \brief Télémétrie runtime (puissance, disponibilité, dernière action).
* \return LoadTelemetry issue de l'état courant de l'appareil nymea.
*/
virtual LoadTelemetry telemetry() const = 0;
/*!
* \brief Construit l'entrée §5 loads[] pour le SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). Source unique pour l'évaluation des
* verrous (minStage/maxStage) JAMAIS \c QDateTime::currentDateTime() côté adaptateur,
* afin que décision (scheduler) et exécution (applyAction) partagent le même temps.
* \return LoadContext incluant declared, limits, needs et télémétrie type-spécifique.
*/
virtual LoadContext toLoadContext(const QDateTime &now) const = 0;
/*!
* \brief Applique l'action et retourne ce qui a réellement é envoyé au matériel.
*
* L'arbitre a déjà écrêté selon les limites et le budget ceci est le second filet.
*
* \param action Action à appliquer. Doit avoir \c reason non vide.
* \param now Temps de cycle (\c ctx.timestamp) MÊME source que toLoadContext(),
* pour que l'évaluation des verrous coïncide avec celle vue par le scheduler.
* \return L'action après écrêtage matériel (peut différer de l'entrée).
* \note Retour silencieux sans effet si \c action.reason est vide.
*/
virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0;
};

View File

@ -1,245 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "relayrouter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <QMap>
#include <QSet>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
namespace {
//! Au-delà, 2^N combinaisons explose. 16 relais = 65536 combos — bien au-delà de tout ECS réel.
constexpr int MaxRelays = 16;
}
RelayRouter::RelayRouter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<LoadConfigRelay> &relays,
int minOnS,
int minOffS,
int priority,
const LoadNeeds &needs,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_minOnS(minOnS)
, m_minOffS(minOffS)
, m_priority(priority)
, m_needs(needs)
{
int n = relays.size();
if (n > MaxRelays) {
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "" << n
<< "relais > max" << MaxRelays << ": tronqué.";
n = MaxRelays;
}
// Paliers DÉRIVÉS : toutes les sommes de sous-ensembles, dédupliquées par puissance, triées
// (QMap = clés croissantes), 0 inclus (sous-ensemble vide = masque 0). Pour des puissances
// identiques (ex. deux relais 1000 W), on garde la première combinaison rencontrée.
QMap<int, QList<QString>> byPower;
for (int mask = 0; mask < (1 << n); ++mask) {
int sum = 0;
QList<QString> set;
for (int i = 0; i < n; ++i) {
if (mask & (1 << i)) {
sum += relays.at(i).powerW;
set.append(relays.at(i).thingId);
}
}
if (!byPower.contains(sum))
byPower.insert(sum, set);
}
for (auto it = byPower.constBegin(); it != byPower.constEnd(); ++it) {
m_levels.append(it.key());
m_relayMapping.append(it.value());
}
// byPower contient toujours la clé 0 (masque vide) → m_levels[0] == 0.
Q_ASSERT(!m_levels.isEmpty() && m_levels.first() == 0);
}
LoadDescriptor RelayRouter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("relay-router");
d.priority = m_priority;
// Paliers DÉRIVÉS exposés à l'optimiseur (watts) — JAMAIS les relais.
d.declared.powerLevels = m_levels;
d.declared.maxPowerW = m_levels.isEmpty() ? 0 : m_levels.last();
d.limits.minOnS = m_minOnS;
d.limits.minOffS = m_minOffS;
d.needs = m_needs;
d.supportedKinds = { LoadAction::Setpoint };
return d;
}
LoadTelemetry RelayRouter::telemetry() const
{
LoadTelemetry t;
t.available = true;
t.lastActionAt = m_lastActionAt;
// currentPowerW :
// - MESURÉ dès qu'au moins un relais du palier expose un state "currentPower" (somme).
// Un thermostat coupé (relais ON mais 0 W) → 0, jamais de puissance fantôme.
// - NOMINAL commandé (repli) si aucun relais actif ne mesure (GPIO bool nu). Dans ce cas
// currentPowerW reflète le COMMANDÉ, pas le mesuré (contrat rév. 3 §3.a).
double power = 0;
bool metered = false;
const QList<QString> activeRelays = m_currentStage < m_relayMapping.size()
? m_relayMapping.at(m_currentStage)
: QList<QString>();
for (const QString &thingId : activeRelays) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay)
continue;
if (!relay->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
metered = true;
power += relay->stateValue("currentPower").toDouble();
}
}
if (!metered && m_currentStage > 0 && m_currentStage < m_levels.size())
power = m_levels.at(m_currentStage); // nominal commandé
t.currentPowerW = power;
return t;
}
LoadContext RelayRouter::toLoadContext(const QDateTime &now) const
{
Q_UNUSED(now) // pas de fenêtre de verrou exposée : le verrou est INTERNE (frontière rév. 3).
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("relay-router");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared; // powerLevels/maxPowerW dérivés (watts)
ctx.needs = m_needs;
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
// Aucun stage/minStage/maxStage : ces champs n'existent plus (rév. 2) et le relais ne
// franchit pas la frontière. L'optimiseur ne voit que des watts.
return ctx;
}
LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Setpoint)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
int newStage = stageForPower(action.powerW);
// Verrou anti-rebond INTERNE (clamp), au temps de cycle. Bypass si force==true (repli L2).
if (!action.force) {
int minStage, maxStage;
lockWindow(now, minStage, maxStage);
newStage = qBound(minStage, newStage, maxStage);
}
LoadAction applied = action;
applied.powerW = m_levels.at(newStage);
applied.estimatedPowerW = m_levels.at(newStage);
if (newStage == m_currentStage)
return applied; // idempotent — pas de re-commutation
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "→ consigne" << qRound(action.powerW) << "W → palier"
<< m_levels.at(newStage) << "W"
<< (action.force ? "(force)" : "")
<< "|" << action.reason;
applyRelayStage(newStage);
m_currentStage = newStage;
m_lastSwitch = now;
m_lastActionAt = now;
return applied;
}
// ---- privé ---------------------------------------------------------------
int RelayRouter::stageForPower(double powerW) const
{
int stage = 0; // m_levels[0] == 0 garanti
for (int i = 0; i < m_levels.size(); ++i) {
if (m_levels.at(i) <= powerW)
stage = i;
else
break; // m_levels triés croissants
}
return stage;
}
void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
{
const int topStage = m_levels.size() - 1;
const bool valid = m_lastSwitch.isValid();
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
// Plancher : si ON et minOn non écoulé → interdit de descendre (puissance engagée non-coupable).
minStage = (m_currentStage > 0 && valid && elapsed < m_minOnS) ? m_currentStage : 0;
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
maxStage = (m_currentStage == 0 && valid && elapsed < m_minOffS) ? 0 : topStage;
}
void RelayRouter::applyRelayStage(int stage)
{
// Set CIBLE du palier (delta complet : chaque relais connu amené à son état on/off cible).
// Gère les mappings NON-CASCADÉS (monter d'un palier peut éteindre des relais).
const QSet<QString> wantOn = [&]() {
QSet<QString> s;
if (stage < m_relayMapping.size())
for (const QString &id : m_relayMapping.at(stage))
s.insert(id);
return s;
}();
QSet<QString> allRelays;
for (const auto &list : m_relayMapping)
for (const QString &id : list)
allRelays.insert(id);
auto writeRelay = [&](const QString &thingId, bool on) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "— relais non trouvé:" << thingId;
return;
}
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (!powerStateType.id().isNull()) {
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
m_thingManager->executeAction(powerAction);
} else {
relay->setStateValue("power", on); // repli mock
}
};
// off-before-on : couper d'abord les relais hors-cible, puis enclencher la cible → pas de
// sur-puissance transitoire (somme des deux paliers) sur une transition non-cascadée.
for (const QString &id : allRelays)
if (!wantOn.contains(id))
writeRelay(id, false);
for (const QString &id : wantOn)
writeRelay(id, true);
}

View File

@ -1,109 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QList>
#include <QString>
#include "iloadadapter.h"
#include "../types/loadconfig.h" // LoadConfigRelay
class Thing;
class ThingManager;
/*!
* \brief ROUTEUR de relais (contrat rév. 3) traduit un \c Setpoint(W) de l'optimiseur en
* combinaison de things \c power, côté experience-plugin.
*
* \par Frontière rév. 3
* L'optimiseur (scheduler) est **watt-pur** : il émet \c LoadAction{Setpoint, powerW}. Le
* `RelayRouter` est la couche **sous** l'optimiseur qui connaît les relais : il mappe
* \c powerW la combinaison atteignable la plus haute \c powerW, applique l'anti-rebond
* (\c minOn/minOff) **ici** (plus dans le scheduler), commute les relais via
* \c ThingManager::executeAction (interface \c power), et agrège \c currentPowerW. **Aucun
* relais, aucun index de combinaison ne remonte dans le \c LoadContext** (seuls les watts
* dérivés `powerLevels`/`maxPowerW` y figurent).
*
* \par Paliers DÉRIVÉS
* Les \c powerLevels (et \c maxPowerW) sont **calculés** depuis la liste de relais : toutes
* les sommes de sous-ensembles atteignables, dédupliquées, triées, \c 0 inclus. **Source de
* vérité unique = les relais** (l'app affiche le même calcul, l'optimiseur les lit pour
* l'arrondi/résidu).
*
* \invariant \c supportedKinds == { Setpoint }. Autres kinds : retour sans effet.
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant Verrous \c minOn/minOff appliqués en INTERNE (clamp), bypassés si \c force==true
* (repli L2). **Temps = paramètre** (cf. \c ILoadAdapter) : \c now reçu, jamais l'horloge.
* \invariant Transition relais en **off-before-on** : coupe d'abord les relais hors-cible.
*/
class RelayRouter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Résout les ThingIds des relais et exécute les actions \c power.
* \param id Identifiant LOGIQUE de la charge (rév. 3 : plus un thingId unique).
* \param label Nom lisible (logs, app).
* \param relays Liste \c {thingId, powerW} des relais \c power. Les paliers atteignables
* sont dérivés de toutes leurs combinaisons.
* \param minOnS/minOffS Verrous anti-rebond (protection relais/compresseur), appliqués ici.
* \param priority Rang dans le waterfall (1 = servi en premier).
* \param needs Besoins déclarés (exposés via descriptor().needs).
* \param parent Propriétaire Qt.
*/
explicit RelayRouter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<LoadConfigRelay> &relays,
int minOnS,
int minOffS,
int priority,
const LoadNeeds &needs = LoadNeeds(),
QObject *parent = nullptr);
//! \return LoadDescriptor : adapter="relay-router", powerLevels/maxPowerW DÉRIVÉS, needs.
LoadDescriptor descriptor() const override;
//! \return currentPowerW = somme des \c currentPower des relais ON (mesuré), sinon nominal commandé.
LoadTelemetry telemetry() const override;
//! \return LoadContext §5 (watts uniquement : powerLevels, currentPowerW — aucun relais).
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique un \c Setpoint(W) : mappe en combinaison de relais, clampe par les verrous,
* commute (off-before-on), publie l'état.
* \param action LoadAction kind \c Setpoint (\c powerW). Autres kinds : retour sans effet.
* \param now Temps de cycle (verrous + estampille).
* \return L'action après écrêtage (\c powerW = palier réellement appliqué).
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
//! \brief Palier courant (0 = tout coupé).
int currentStage() const { return m_currentStage; }
//! \brief Puissance (W) du palier courant.
double currentSetpointW() const { return m_currentStage < m_levels.size() ? m_levels.at(m_currentStage) : 0.0; }
private:
//! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0).
int stageForPower(double powerW) const;
//! Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE, plus exposé).
void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const;
void applyRelayStage(int stage);
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QList<int> m_levels; //!< Paliers W dérivés, triés, [0]=0.
QList<QList<QString>> m_relayMapping; //!< ThingIds ON par palier (dérivé des combinaisons).
int m_minOnS;
int m_minOffS;
int m_priority;
LoadNeeds m_needs;
int m_currentStage = 0;
QDateTime m_lastSwitch; //!< Dernier changement (null = jamais).
QDateTime m_lastActionAt;
};

View File

@ -1,235 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "sgreadyadapter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <QSet>
#include <algorithm>
#include <climits>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
SgReadyAdapter::SgReadyAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QHash<int, QList<QString>> &stateRelays,
const QHash<int, double> &estimatedPowerW,
int minStateHoldS,
int priority,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_stateRelays(stateRelays)
, m_estimatedPowerW(estimatedPowerW)
, m_minStateHoldS(minStateHoldS)
, m_priority(priority)
{
m_states = m_stateRelays.keys();
std::sort(m_states.begin(), m_states.end());
Q_ASSERT(!m_states.isEmpty());
Q_ASSERT(m_stateRelays.contains(2)); // état 2 (normal) = repli sûr obligatoire
}
LoadDescriptor SgReadyAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("sg-ready");
d.priority = m_priority;
d.declared.states = m_states;
d.declared.estimatedPowerW = m_estimatedPowerW;
d.limits.minStateHoldS = m_minStateHoldS;
d.supportedKinds = { LoadAction::State };
return d;
}
LoadTelemetry SgReadyAdapter::telemetry() const
{
LoadTelemetry t;
t.available = true;
t.lastActionAt = m_lastActionAt;
// Base du recrédit budget = puissance ALLOUÉE de l'état (déclaré), pas la conso mesurée
// (états 1/2 → 0 ; états 3/4 → P3/P4). Cf. invariant 8.
t.currentPowerW = m_estimatedPowerW.value(m_currentState, 0.0);
return t;
}
LoadContext SgReadyAdapter::toLoadContext(const QDateTime &now) const
{
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("sg-ready");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared;
ctx.limits = descriptor().limits;
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
ctx.telemetry.state = m_currentState;
ctx.telemetry.lastSwitch = m_lastSwitch;
// Fenêtre de verrou évaluée au temps de cycle (protection court-cycling PAC).
lockWindow(now, ctx.telemetry.minState, ctx.telemetry.maxState);
return ctx;
}
LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::State)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
// Écrêtage à un état déclaré (borne puis exigence d'appartenance).
int newState = qBound(m_states.first(), action.state, m_states.last());
if (!m_stateRelays.contains(newState)) {
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— état non déclaré:" << action.state << "→ ignoré.";
return action;
}
if (newState == m_currentState)
return action; // Idempotent
// Verrou minStateHold évalué au temps de cycle (même fenêtre que le scheduler) —
// bypassé si force == true (L2 watchdog → état 2).
if (!action.force && lockActive(newState, now)) {
qCDebug(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— verrou minStateHold actif, état" << newState << "ignoré.";
return action;
}
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "→ état" << newState
<< "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)"
<< "|" << action.reason;
applyStateRelays(m_currentState, newState);
m_currentState = newState;
m_lastSwitch = now;
m_lastActionAt = now;
LoadAction applied = action;
applied.state = newState;
applied.estimatedPowerW = m_estimatedPowerW.value(newState, 0.0);
return applied;
}
// ---- privé ---------------------------------------------------------------
void SgReadyAdapter::lockWindow(const QDateTime &now, int &minState, int &maxState) const
{
const int lo = m_states.first();
const int hi = m_states.last();
const bool valid = m_lastSwitch.isValid();
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
if (valid && elapsed < m_minStateHoldS) {
// Gel total : la PAC doit tenir son état (protection court-cycling compresseur).
minState = maxState = m_currentState;
} else {
minState = lo;
maxState = hi;
}
}
bool SgReadyAdapter::lockActive(int newState, const QDateTime &now) const
{
// MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident.
int minState, maxState;
lockWindow(now, minState, maxState);
return newState < minState || newState > maxState;
}
int SgReadyAdapter::transientHarm(int state)
{
// Transitoire le plus doux d'abord : neutre (2) < recommandation (3) < blocage (1) < forcé (4).
switch (state) {
case 2: return 0; // neutre
case 3: return 1; // recommandation (run doux)
case 1: return 2; // blocage (coupe le chauffage)
case 4: return 3; // forcé (démarrage franc compresseur)
default: return 2; // combinaison hors-norme : prudence
}
}
int SgReadyAdapter::stateForRelays(const QList<QString> &onRelays) const
{
const QSet<QString> want(onRelays.begin(), onRelays.end());
for (auto it = m_stateRelays.constBegin(); it != m_stateRelays.constEnd(); ++it) {
const QSet<QString> s(it.value().begin(), it.value().end());
if (s == want)
return it.key();
}
return -1;
}
QSet<QString> SgReadyAdapter::allRelays() const
{
QSet<QString> all;
for (const auto &list : m_stateRelays)
for (const QString &id : list)
all.insert(id);
return all;
}
void SgReadyAdapter::applyStateRelays(int fromState, int toState)
{
const QList<QString> targetList = m_stateRelays.value(toState);
const QSet<QString> targetOn(targetList.begin(), targetList.end());
const QList<QString> fromList = m_stateRelays.value(fromState);
const QSet<QString> currentOn(fromList.begin(), fromList.end());
// Relais dont l'état change lors de la transition.
QStringList changed;
for (const QString &relay : allRelays())
if (targetOn.contains(relay) != currentOn.contains(relay))
changed << relay;
auto writeRelay = [&](const QString &thingId, bool on) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— relais non trouvé:" << thingId;
return;
}
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (!powerStateType.id().isNull()) {
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
m_thingManager->executeAction(powerAction);
} else {
relay->setStateValue("power", on); // repli mock
}
};
// Contrat d'atomicité : si 2 relais (ou +) changent, commuter d'abord celui dont le
// TRANSITOIRE est le plus doux (neutre/reco plutôt que blocage/forcé), puis les autres.
if (changed.size() >= 2) {
QString best;
int bestHarm = INT_MAX;
for (const QString &r : changed) {
QSet<QString> transient = currentOn;
if (targetOn.contains(r)) transient.insert(r); else transient.remove(r);
const int h = transientHarm(stateForRelays(transient.values()));
if (h < bestHarm) { bestHarm = h; best = r; }
}
writeRelay(best, targetOn.contains(best));
changed.removeAll(best);
}
// Relais restants amenés à leur valeur cible.
for (const QString &r : changed)
writeRelay(r, targetOn.contains(r));
}

View File

@ -1,125 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QList>
#include <QHash>
#include <QString>
#include "iloadadapter.h"
class Thing;
class ThingManager;
/*!
* \brief Adaptateur SG-Ready (PAC) interface "sg-ready", action \c kind:State.
*
* Pilote une pompe à chaleur via 2 contacts SG-Ready (encodage 2 bits 4 états NORMÉS) :
* 1 = blocage (EVU-Sperre) · 2 = normal (mains off : la PAC décide)
* 3 = recommandation (surplus) · 4 = forcé (boost)
*
* Les 4 états ne sont PAS des paliers de puissance : ils sont qualitatifs, la PAC les
* interprète selon SA logique. \c m_stateRelays[état] = ThingIds powerswitch à mettre ON
* pour cet état (encodage câblé par l'installateur ; les autres relais sont OFF).
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant applyAction() applique le verrou \c minStateHoldS (protection court-cycling
* compresseur) SAUF si \c action.force == true (réservé L2 watchdog état 2).
* \invariant L'état est écrêté à l'ensemble \c m_states avant envoi matériel.
* \invariant Seul le kind State est traité ; les autres kinds retournent sans effet.
*
* \par Contrat d'atomicité (transport déporté Shelly/Modbus à venir)
* Une transition d'état commute parfois 2 relais (ex. 24 : 0011). Les contacts
* doivent être écrits **aussi atomiquement que possible**, et l'ORDRE de commutation
* doit éviter tout **état actif parasite** : on passe par le transitoire le plus DOUX
* (neutre = état 2, sinon recommandation = état 3) plutôt que par blocage (1) ou forcé
* (4). \c applyStateRelays() choisit cet ordre. En GPIO local le transitoire dure des µs,
* mais l'intention est portée par l'adaptateur pour rester correcte sur un bus lent.
*/
class SgReadyAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds.
* \param id Identifiant logique de la charge.
* \param label Nom lisible (logs, app).
* \param stateRelays état liste de ThingIds powerswitch ON (encodage 2 bits SG-Ready).
* \param estimatedPowerW Puissance estimée (W) par état (déclaré installateur, approx.).
* \param minStateHoldS Durée minimale de maintien d'état (s) protection court-cycling.
* \param priority Rang dans le waterfall (protocole §5 : valeur plus BASSE = servi en premier).
* \param parent Propriétaire Qt.
*/
explicit SgReadyAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QHash<int, QList<QString>> &stateRelays,
const QHash<int, double> &estimatedPowerW,
int minStateHoldS,
int priority,
QObject *parent = nullptr);
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime. \c currentPowerW = puissance ALLOUÉE de l'état courant
* (\c declared.estimatedPowerW : 0 pour états 1/2, P3/P4 pour 3/4). C'est la base du
* recrédit budget PAS la conso mesurée de la PAC (l'état 2 autonome est déjà au
* compteur, invariant 8 : la recréditer double-compterait).
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 (adapter="sg-ready").
* \param now Temps de cycle (\c ctx.timestamp) source unique de la fenêtre minState/maxState.
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique un changement d'état SG-Ready (2 relais, transition atomique-douce).
* \param action LoadAction de kind State. Autres kinds : retour sans effet.
* \param now Temps de cycle MÊME source que toLoadContext() (verrou + lastSwitch).
* \return L'action après écrêtage (state borné à l'ensemble déclaré).
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief État SG-Ready courant (1-4). */
int currentState() const { return m_currentState; }
private:
/*!
* \brief Fenêtre d'états autorisée à \p now par le verrou minStateHold (symétrique).
* \param[out] minState / maxState Gel total (== \c m_currentState) si \c minStateHold
* non écoulé ; sinon [min, max] des états déclarés.
*/
void lockWindow(const QDateTime &now, int &minState, int &maxState) const;
bool lockActive(int newState, const QDateTime &now) const;
//! Applique l'ensemble de relais de \p toState en passant par le transitoire le plus
//! doux (cf. contrat d'atomicité). \p fromState = état courant (pour l'ordre).
void applyStateRelays(int fromState, int toState);
//! Rang de nocivité d'un état comme TRANSITOIRE (2 neutre < 3 reco < 1 blocage < 4 forcé).
static int transientHarm(int state);
//! État correspondant à un ensemble de relais ON (-1 si aucun état ne correspond).
int stateForRelays(const QList<QString> &onRelays) const;
QSet<QString> allRelays() const;
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QHash<int, QList<QString>> m_stateRelays; //!< état → ThingIds ON.
QHash<int, double> m_estimatedPowerW; //!< état → W estimés (déclaré).
QList<int> m_states; //!< États déclarés triés croissants.
int m_minStateHoldS;
int m_priority;
int m_currentState = 2; //!< Démarrage en NORMAL (mains off).
QDateTime m_lastSwitch; //!< Dernier changement d'état (null = jamais).
QDateTime m_lastActionAt;
};

View File

@ -1,118 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "loadconfigstore.h"
#include "plugininfo.h"
#include <nymeasettings.h>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonParseError>
LoadConfigStore::LoadConfigStore(QObject *parent)
: QObject{parent}
{
if (load())
qCDebug(dcNymeaEnergy()) << "[LoadConfigStore]" << m_configs.count()
<< "config(s) de charge pilotée chargée(s) depuis" << filePath();
else
qCDebug(dcNymeaEnergy()) << "[LoadConfigStore] Aucune config de charge pilotée — démarrage à vide.";
}
QString LoadConfigStore::filePath() const
{
const QString env = QString(qgetenv("NYMEA_ENERGY_LOAD_CONFIG"));
if (!env.isEmpty())
return env;
return NymeaSettings::storagePath() + "/energy-load-configuration.json";
}
bool LoadConfigStore::load()
{
const QString path = filePath();
if (!QFileInfo::exists(path))
return false;
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Ouverture impossible:" << path << file.errorString();
return false;
}
const QByteArray data = file.readAll();
file.close();
QJsonParseError err;
const QJsonDocument doc = QJsonDocument::fromJson(data, &err);
if (err.error != QJsonParseError::NoError) {
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] JSON invalide:" << err.errorString();
return false;
}
LoadConfigs loaded;
const QJsonArray loads = doc.object().value("loads").toArray();
for (const QJsonValue &v : loads) {
const LoadConfig c = LoadConfig::fromMap(v.toObject().toVariantMap());
QString why;
if (!c.isValid(&why)) {
// Tolérance ascendante : on ignore une entrée corrompue plutôt que de tout perdre.
qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Entrée ignorée (invalide):" << why;
continue;
}
loaded.append(c);
}
m_configs = loaded;
return true;
}
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error)
{
// Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté).
for (const LoadConfig &c : configs) {
QString why;
if (!c.isValid(&why)) {
if (error) *error = QStringLiteral("Config \"%1\" invalide : %2").arg(c.label(), why);
return false;
}
}
const LoadConfigs previous = m_configs;
m_configs = configs;
if (!save(error)) {
m_configs = previous; // rollback mémoire si l'écriture échoue
return false;
}
emit changed();
return true;
}
bool LoadConfigStore::save(QString *error) const
{
QJsonArray loads;
for (const LoadConfig &c : m_configs)
loads.append(QJsonObject::fromVariantMap(c.toMap()));
QJsonObject root;
root.insert("version", 1);
root.insert("loads", loads);
// Écriture atomique (QSaveFile = tmp + rename) — jamais de fichier mi-écrit.
QSaveFile file(filePath());
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
if (error) *error = QStringLiteral("Écriture impossible : %1").arg(file.errorString());
return false;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
if (!file.commit()) {
if (error) *error = QStringLiteral("Commit échoué : %1").arg(file.errorString());
return false;
}
qCDebug(dcNymeaEnergy()) << "[LoadConfigStore]" << m_configs.count() << "config(s) persistée(s) dans" << filePath();
return true;
}

View File

@ -1,49 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QString>
#include "../types/loadconfig.h"
/*!
* \brief Store persistant des configs de charge pilotée (contrat etmvariableload §4).
*
* Master store unique : \c setConfigs() valide, **persiste** (\c /var/lib/nymea/\c
* energy-load-configuration.json, à côté de \c energy-manager-configuration.json) et émet
* \c changed() l'arbitre reconstruit ses adaptateurs sur ce signal, le handler notifie l'app.
*
* Format fichier : \c {"version":1,"loads":[ <LoadConfig §4>, ... ]}. Le tableau \c loads[]
* est **mot pour mot** le \c LoadDescriptor §4 (jonction inter-repos avec l'app).
*
* \invariant Écriture atomique (fichier temporaire + \c rename) jamais de fichier mi-écrit.
* \invariant \c setConfigs() rejette en bloc (rien persisté) si une seule entrée est invalide.
*/
class LoadConfigStore : public QObject
{
Q_OBJECT
public:
explicit LoadConfigStore(QObject *parent = nullptr);
/*! \brief Configs actuellement en mémoire (chargées au démarrage ou via setConfigs). */
LoadConfigs configs() const { return m_configs; }
/*!
* \brief Remplace l'ensemble des configs : valide tout persiste \c emit changed().
* \param configs Nouvel ensemble (remplace l'existant l'app envoie la liste complète).
* \param[out] error Message FR si rejet (aucune écriture dans ce cas).
* \return true si validé + persisté ; false si invalide ou échec d'écriture.
*/
bool setConfigs(const LoadConfigs &configs, QString *error = nullptr);
signals:
/*! \brief Émis après une persistance réussie de \c setConfigs(). */
void changed();
private:
QString filePath() const;
bool load();
bool save(QString *error) const;
LoadConfigs m_configs;
};

View File

@ -1,373 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "energyarbitrator.h"
#include "adapters/evadapter.h"
#include "adapters/sgreadyadapter.h"
#include "adapters/etmvariableloadadapter.h"
#include "adapters/relayrouter.h"
#include "config/loadconfigstore.h"
#include "types/loadconfig.h"
#include "scheduler/rulebasedscheduler.h"
#include "types/surpluscontext.h"
#include "types/plan.h"
#include "../rootmeter.h"
#include "../evcharger.h"
#include "plugininfo.h"
#include <energymanager.h>
#include <QTimer>
namespace {
//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur.
constexpr int MeterWatchdogPeriodMs = 30 * 1000; // 30 s
//! Seuil de silence compteur au-delà duquel le mode dégradé L2 est déclenché.
constexpr int MeterSilenceThresholdS = 90; // 90 s
}
EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
SpotMarketManager *sm,
EnergyManagerConfiguration *conf,
QObject *parent)
: SmartChargingManager(em, tm, sm, conf, parent)
, m_scheduler(new RuleBasedScheduler(this, this))
, m_tm(tm)
{
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
// La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre
// et reste testable par injection (symétrique de simulationCallUpdate). Seuls les
// DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en
// simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130).
#ifndef ENERGY_SIMULATION
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4).
connect(em, &EnergyManager::powerBalanceChanged, this, [this]() {
recordMeterUpdate(QDateTime::currentDateTime());
});
// QTimer (et non signal) : doit rester actif quand le compteur est muet.
m_meterWatchdog = new QTimer(this);
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
m_meterWatchdog->start();
#else
Q_UNUSED(em)
#endif
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
}
void EnergyArbitrator::runSurplusPlanning(const QDateTime &now)
{
planSurplusCharging(now);
}
void EnergyArbitrator::runSpotMarketPlanning(const QDateTime &now)
{
planSpotMarketCharging(now);
}
const QHash<EvCharger *, ChargingActions> &EnergyArbitrator::scheduledActions() const
{
return internalChargingActions();
}
void EnergyArbitrator::doExecuteChargingAction(EvCharger *charger,
const ChargingAction &action,
const QDateTime &now)
{
executeChargingAction(charger, action, now);
}
const QHash<ThingId, EvCharger *> &EnergyArbitrator::registeredEvChargers() const
{
return internalEvChargers();
}
RootMeter *EnergyArbitrator::registeredRootMeter() const
{
return internalRootMeter();
}
void EnergyArbitrator::registerSgReadyAdapter(SgReadyAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_sgReadyAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter déjà enregistré:" << id;
return;
}
adapter->setParent(this);
m_sgReadyAdapters[id] = adapter;
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_loadAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
return;
}
adapter->setParent(this);
m_loadAdapters[id] = adapter; // upcast EtmVariableLoadAdapter* → ILoadAdapter*
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::registerRelayRouter(RelayRouter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_loadAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
return;
}
adapter->setParent(this);
m_loadAdapters[id] = adapter; // upcast RelayRouter* → ILoadAdapter*
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store)
{
m_loadConfigStore = store;
if (!store)
return;
connect(store, &LoadConfigStore::changed, this, &EnergyArbitrator::rebuildLoadAdapters);
rebuildLoadAdapters(); // construction initiale depuis la config persistée
}
void EnergyArbitrator::rebuildLoadAdapters()
{
// Purge des adaptateurs construits depuis la config précédente (ILoadAdapter n'est pas QObject :
// les concrets le sont — dynamic_cast pour deleteLater).
for (ILoadAdapter *a : m_loadAdapters)
if (QObject *o = dynamic_cast<QObject *>(a))
o->deleteLater();
m_loadAdapters.clear();
if (!m_loadConfigStore)
return;
for (const LoadConfig &c : m_loadConfigStore->configs()) {
// enabled==false : rôle déclaré mais EXCLU de l'arbitrage (contrat §9) — pas d'adaptateur.
if (!c.enabled())
continue;
LoadNeeds needs;
needs.dailyDeadline = c.needs().dailyDeadline();
needs.minEnergyWhPerDay = c.needs().minEnergyWhPerDay();
// La distinction de TYPE vit ICI (rév. 3) ; au-dessus, tout est ILoadAdapter (Setpoint W).
ILoadAdapter *adapter = nullptr;
if (c.isRelayRouter()) {
adapter = new RelayRouter(m_tm, c.id(), c.label(), c.relaysList(),
c.minOnS(), c.minOffS(), c.priority(), needs, this);
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router construit depuis config:"
<< c.label() << "(" << c.relaysList().count() << "relais)";
} else {
adapter = new EtmVariableLoadAdapter(m_tm, c.id(), c.label(), c.powerLevelsInt(),
c.maxPowerW(), c.priority(), needs, this);
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:"
<< c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")";
}
m_loadAdapters[c.id()] = adapter;
}
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_loadAdapters.count()
<< "charge(s) pilotée(s) active(s) (config).";
}
void EnergyArbitrator::update(const QDateTime &currentDateTime)
{
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
// Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner.
// SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery
// 5.planSpot 6.planSurplus 7.adjustEv
// ETM : idem 1-4 ; insertions ETM entre 4 et 7 ;
// planSpot + planSurplus appelés via m_scheduler->getPlan() (position 5-6).
// 1-4 : préparation + sécurité (même ordre que l'amont)
updateManualSoCsWithoutMeter(currentDateTime);
prepareInformation(currentDateTime);
verifyOverloadProtection(currentDateTime);
verifyOverloadProtectionRecovery(currentDateTime);
// Mode dégradé L2 : la sécurité (L4 ci-dessus) reste active, mais on SUSPEND la
// planification et le dispatch. Replanifier sur le cache d'un compteur mort
// rallumerait les charges que le watchdog vient de couper → oscillation. Les
// consignes de repli (posées à la transition) tiennent jusqu'au retour du compteur.
if (m_degradedMode) {
qCDebug(dcNymeaEnergy()) << "[Arbitre] Mode dégradé L2 actif — planification suspendue.";
return;
}
// ETM-only : sync adapters + proxy planification → log [Arbitre]
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
syncAdapters();
SurplusContext ctx = buildContext(currentDateTime);
Plan plan = m_scheduler->getPlan(ctx);
Slot slot = plan.slotCovering(currentDateTime);
for (const LoadAction &action : slot.actions) {
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
<< action.loadId << "" << action.reason
<< "| activé:" << action.chargingEnabled
<< "| courant:" << action.currentA << "A"
<< "| phases:" << action.phaseCount
<< "| stratégie:" << plan.strategy;
}
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
applyActionsToAdapters(slot, currentDateTime); // PAC (kind==State) → m_sgReadyAdapters
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
}
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
{
SurplusContext ctx;
ctx.timestamp = now;
// --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) ---
RootMeter *meter = internalRootMeter();
if (meter) {
// currentPower() < 0 → export ; > 0 → import (convention amont SCM l.1141)
const double p = meter->currentPower();
ctx.meter.importW = qMax(0.0, p);
ctx.meter.exportW = qMax(0.0, -p);
ctx.meter.perPhaseA = {
meter->currentPhaseA(),
meter->currentPhaseB(),
meter->currentPhaseC()
};
}
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
// SurplusBattery : déféré 3f
// --- loads[] : EV adapters --- (now = ctx.timestamp : source unique des verrous)
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : charges pilotées en watts (relay-router + etmvariableload), polymorphes ---
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : SG-Ready adapters (PAC) ---
for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
return ctx;
}
void EnergyArbitrator::syncAdapters()
{
// Crée les adapters manquants
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
const QString id = it.key().toString();
if (!m_adapters.contains(id))
m_adapters[id] = new EvAdapter(it.value(), this);
}
// Supprime les adapters obsolètes
for (const QString &id : m_adapters.keys()) {
if (!internalEvChargers().contains(ThingId(id)))
m_adapters.take(id)->deleteLater();
}
}
void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime &now)
{
for (const LoadAction &action : slot.actions) {
// L'adaptateur applique, écrête et verrouille — il ne décide pas (règle 2).
if (action.kind == LoadAction::State) {
SgReadyAdapter *adapter = m_sgReadyAdapters.value(action.loadId);
if (adapter)
adapter->applyAction(action, now);
else
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur SG-Ready:" << action.loadId;
} else if (action.kind == LoadAction::Setpoint) {
// Dispatch AGNOSTIQUE au type : routage par loadId, le polymorphisme ILoadAdapter
// absorbe (relay-router OU etmvariableload). Un Setpoint EV n'est PAS dans cette table
// → ignoré ici (dispatché par adjustEvChargers() amont).
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId);
if (adapter)
adapter->applyAction(action, now);
}
}
}
void EnergyArbitrator::onMeterWatchdogTick()
{
// Déclencheur réel (QTimer, horloge murale) → délègue à la logique injectable.
evaluateMeterFreshness(QDateTime::currentDateTime());
}
void EnergyArbitrator::recordMeterUpdate(const QDateTime &now)
{
m_lastMeterUpdate = now;
if (m_degradedMode) {
qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2.";
m_degradedMode = false;
emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant)
}
}
void EnergyArbitrator::evaluateMeterFreshness(const QDateTime &now)
{
if (!m_lastMeterUpdate.isValid())
return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent).
const qint64 silentS = m_lastMeterUpdate.secsTo(now);
if (silentS <= MeterSilenceThresholdS)
return;
if (m_degradedMode)
return; // Déjà en repli — les consignes tiennent, pas de ré-émission (anti-oscillation).
qCWarning(dcNymeaEnergy()) << "[Arbitre] Compteur muet depuis" << silentS
<< "s (>" << MeterSilenceThresholdS << "s) — mode dégradé L2.";
applyDegradedMode(now);
}
void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
{
m_degradedMode = true;
emit chargingSchedulesChanged(); // pousse degradedMode=true (notification client L2)
const QString reason =
QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
// Charges pilotées en watts (relay-router ET etmvariableload) : repli Setpoint(0) force=true
// (contrat rév. 3 §9). Boucle sur TOUS les ILoadAdapter — ne PAS oublier un type, sinon des
// relais ECS resteraient allumés en compteur muet (trou T2 recréé). force=true → pour le
// relay-router, coupe TOUS les relais (bypass minOn/minOff) ; pour etmvariableload, setpoint 0.
for (ILoadAdapter *adapter : m_loadAdapters) {
LoadAction la;
la.loadId = adapter->descriptor().id;
la.kind = LoadAction::Setpoint;
la.powerW = 0;
la.force = true;
la.reason = reason;
adapter->applyAction(la, now);
}
// EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge
// DÉJÀ en cours au courant minimum (force=true, bypass lock). Une borne branchée mais
// non chargeante reste off (off volontaire possible : HC/spot à venir) ; débranchée →
// aucune action. La garantie "jamais 0 A si branché" relève du failsafe L1 de la borne.
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
EvCharger *ev = it.value();
if (ev->available() && ev->charging())
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
}
// SG-Ready (PAC) : repli en état 2 (NORMAL — mains off), JAMAIS état 1 (blocage).
// Sous compteur muet on cesse de piloter : la PAC chauffe selon son propre thermostat
// (la bloquer = maison qui ne chauffe plus sans raison visible). force=true → bypass minStateHold.
for (SgReadyAdapter *adapter : m_sgReadyAdapters) {
LoadAction la;
la.loadId = adapter->descriptor().id;
la.kind = LoadAction::State;
la.state = 2;
la.force = true;
la.reason = reason;
adapter->applyAction(la, now);
}
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
}

View File

@ -1,245 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include "../smartchargingmanager.h"
#include "scheduler/ischeduler.h"
#include "types/surpluscontext.h"
#include "types/plan.h"
#include <QDateTime>
class QTimer;
class EvAdapter;
class SgReadyAdapter;
class EtmVariableLoadAdapter;
class RelayRouter;
class ILoadAdapter;
class LoadConfigStore;
class RuleBasedScheduler;
/*!
* \brief Arbitre central ETM remplace SmartChargingManager::update() (ETM_ARBITRATOR).
*
* Hérite de SmartChargingManager pour conserver la compatibilité API complète avec
* NymeaEnergyJsonHandler sans modifier le code amont.
* Seul update() est surchargé : préparation sécurité planificateur adapters.
*
* \invariant UN seul arbitre : EnergyArbitrator décide, les EvAdapter exécutent (règle 1).
* \invariant verifyOverloadProtection() est toujours appelée avant la planification (règle 4).
* \invariant Toute LoadAction transmise aux adapters a un \c reason non vide (règle 7).
* \invariant L'absence du root meter n'empêche pas le démarrage cycle ignoré silencieusement.
*/
class EnergyArbitrator : public SmartChargingManager
{
Q_OBJECT
public:
explicit EnergyArbitrator(EnergyManager *energyManager, ThingManager *thingManager,
SpotMarketManager *spotMarketManager,
EnergyManagerConfiguration *configuration,
QObject *parent = nullptr);
/*!
* \brief Déclenche planSurplusCharging() (protégée) appelé par RuleBasedScheduler.
* \param now Instant courant du cycle.
*/
void runSurplusPlanning(const QDateTime &now);
/*!
* \brief Déclenche planSpotMarketCharging() (protégée) appelé par RuleBasedScheduler.
* \param now Instant courant du cycle.
*/
void runSpotMarketPlanning(const QDateTime &now);
/*!
* \brief Actions planifiées (résultat de runSurplus/SpotMarket).
* \return Référence constante vers la table EvCharger* ChargingActions.
* \note Valide seulement après runSurplusPlanning() / runSpotMarketPlanning().
*/
const QHash<EvCharger *, ChargingActions> &scheduledActions() const;
/*!
* \brief Pont d'exécution pour EvAdapter délègue à executeChargingAction() protégée.
* \param charger Borne EV cible.
* \param action ChargingAction à appliquer.
* \param now Instant de l'action (pour les locks anti-rebond).
*/
void doExecuteChargingAction(EvCharger *charger, const ChargingAction &action, const QDateTime &now);
/*!
* \brief Liste des EvCharger enregistrés (lecture seule).
* \return Table ThingId EvCharger*.
*/
const QHash<ThingId, EvCharger *> &registeredEvChargers() const;
/*!
* \brief Root meter courant.
* \return Pointeur ou nullptr si aucun compteur principal n'est enregistré.
*/
RootMeter *registeredRootMeter() const;
/*!
* \brief Enregistre un SgReadyAdapter (PAC) pour inclusion dans le contexte et le dispatch.
* \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique.
* Adopté comme enfant Qt de l'arbitre. Appelé par le test (setup) ou la config production.
*/
void registerSgReadyAdapter(SgReadyAdapter *adapter);
/*!
* \brief Enregistre un EtmVariableLoadAdapter (ECS/routeur, interface \c etmvariableload)
* pour inclusion dans le contexte et le dispatch \c Setpoint (W).
* \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique.
* Adopté comme enfant Qt de l'arbitre. Appelé par le test (setup) ou en T4 la
* construction depuis \c LoadConfig.
* \note Le dispatch distingue un \c Setpoint etmvariableload d'un \c Setpoint EV par le
* \c loadId : seul l'EV n'est PAS dans \c m_loadAdapters (proxy amont jusqu'à 3g).
*/
void registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter);
/*!
* \brief Enregistre un RelayRouter (ECS multipalier, rév. 3) dans la même table d'adaptateurs.
* \param adapter Routeur à enregistrer ; \c descriptor().id unique. Adopté enfant Qt.
* Appelé par le test (setup) ou en prod la construction depuis \c LoadConfig (relays[]).
*/
void registerRelayRouter(RelayRouter *adapter);
/*!
* \brief Branche le store de config charge pilotée : construit les adaptateurs
* \c etmvariableload depuis la config et les reconstruit à chaque \c changed().
*
* Remplace l'enregistrement en dur (le « 3g »). Les charges \c enabled==false ne sont
* PAS construites (exclues de l'arbitrage, contrat §9). La PAC SG-Ready reste hors config.
* \param store Store persistant (propriété de l'appelant ; non adopté).
*/
void setLoadConfigStore(LoadConfigStore *store);
/*!
* \brief Mode dégradé L2 actif (compteur muet > 90 s) override de SmartChargingManager.
* \return \c true tant que les consignes de repli L2 tiennent ; \c false en régime normal.
* \note Exposé dans la notification \c NymeaEnergy.ChargingSchedulesChanged (champ
* \c degradedMode), émise aussi aux transitions de ce flag.
*/
bool degradedMode() const override { return m_degradedMode; }
/*!
* \brief Enregistre une mesure fraîche du compteur à l'instant \p now (logique L2).
*
* Met à jour \c m_lastMeterUpdate et, si le mode dégradé était actif, en sort
* (\c degradedMode=false + notification). \p now = temps de cycle.
* \note Logique injectable (temps en paramètre) en production appelée par le
* handler \c powerBalanceChanged ; en simulation/test appelée directement. Le
* déclencheur réel (signal) est câblé sous \c \#ifndef ENERGY_SIMULATION.
*/
void recordMeterUpdate(const QDateTime &now);
/*!
* \brief Évalue la fraîcheur du compteur à \p now et bascule en mode dégradé si muet >90 s.
*
* Si \c now \c m_lastMeterUpdate > 90 s et pas déjà dégradé \c applyDegradedMode().
* Appliqué à la TRANSITION uniquement (idempotent ensuite). \p now = temps de cycle.
* \note Logique injectable en production appelée par \c onMeterWatchdogTick() (QTimer
* horloge murale, indépendant car le compteur muet fige aussi \c update()) ; en
* simulation/test appelée directement avec le temps simulé. Symétrique de
* \c simulationCallUpdate : déclencheur réel en prod, logique testable par injection.
*/
void evaluateMeterFreshness(const QDateTime &now);
protected:
/*!
* \brief Boucle principale ETM surcharge SmartChargingManager::update().
*
* Ordre garanti :
* 1. updateManualSoCsWithoutMeter()
* 2. prepareInformation()
* 3. verifyOverloadProtection() + verifyOverloadProtectionRecovery()
* (si \c m_degradedMode actif : retour immédiat planification/dispatch suspendus, L2)
* 4. m_scheduler->getPlan() log des decisionReason
* 5. applyActionsToAdapters() (etmvariableload Setpoint W + SG-Ready State) + adjustEvChargers() (EV) dispatch
*
* \param currentDateTime Instant courant (timer ou simulation).
*/
void update(const QDateTime &currentDateTime) override;
private:
/*!
* \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready.
*
* \c ctx.meter.exportW = mesure brute du compteur (AGENTS invariant 8 aucune
* déduction interne). La déduction evReservedW est faite dans le scheduler.
* \param now Temps de cycle (\c ctx.timestamp) : pose \c ctx.timestamp et sert de SOURCE
* UNIQUE à la fenêtre de verrou PAC (\c minState/maxState) calculée par le SgReadyAdapter
* cohérence décision (scheduler) / exécution (applyAction). Les charges etmvariableload
* (ECS/routeur) n'ont pas de fenêtre côté moteur (verrous dans le thing).
*/
SurplusContext buildContext(const QDateTime &now) const;
/*!
* \brief Synchronise m_adapters avec les EvCharger actuellement enregistrés.
* Crée les adapters manquants, supprime les adapters obsolètes.
* \note Découverte etmvariableload/SG-Ready via config câblé en T4.
*/
void syncAdapters();
/*!
* \brief Applique les actions d'un Slot aux LoadAdapters non-EV.
*
* Itère \c slot.actions et dispatche selon le kind : \c State \c m_sgReadyAdapters
* (SgReadyAdapter, PAC). Les actions EV (\c Setpoint) restent dispatchées par
* \c adjustEvChargers() amont jusqu'à 3g ; les charges etmvariableload (\c Setpoint W)
* seront dispatchées via leur adaptateur en T3/T4. L'adaptateur écrête/verrouille lui-même
* et ignore toute action sans \c reason ou de kind non supporté aucune décision ici (règle 2).
* \param slot Créneau courant retourné par le scheduler.
* \param now Temps de cycle (\c ctx.timestamp) transmis aux adaptateurs pour une
* évaluation des verrous cohérente avec celle vue par le scheduler.
*/
void applyActionsToAdapters(const Slot &slot, const QDateTime &now);
/*!
* \brief (Re)construit \c m_loadAdapters depuis \c m_loadConfigStore (rév. 3).
* Purge les adaptateurs existants (deleteLater) puis crée, par config \c enabled==true, un
* \c RelayRouter (si \c relays[]) ou un \c EtmVariableLoadAdapter (sinon). La distinction de
* TYPE vit ici ; au-dessus tout est \c ILoadAdapter (Setpoint W). Appelé au branchement du
* store et à chaque \c LoadConfigStore::changed().
*/
void rebuildLoadAdapters();
/*!
* \brief Déclencheur RÉEL du watchdog L2 (SAFETY.md §L2) slot de \c m_meterWatchdog
* (QTimer 30 s, horloge murale ; câblé sous \c \#ifndef ENERGY_SIMULATION).
*
* Délègue simplement à \c evaluateMeterFreshness(QDateTime::currentDateTime()) : la
* LOGIQUE (seuil 90 s, bascule en dégradé) est dans cette méthode injectable, le QTimer
* n'est que le battement. Indépendant des signaux compteur : reste actif précisément
* quand le compteur est muet (le signal \c powerBalanceChanged ne fire plus, et
* \c update() piloté par le compteur s'arrête aussi). Voir \c evaluateMeterFreshness().
*/
void onMeterWatchdogTick();
/*!
* \brief Applique les consignes de repli L2 (SAFETY.md §L2, Variante B).
*
* Repli CONSERVATEUR (n'initie aucune charge) : ECS palier 0 \c force=true (bypass
* anti-rebond) ; EV en charge clamp courant minimum borne ; EV branché non chargeant
* ou débranché aucune action (planification en cours respectée ; "jamais 0 A si
* branché" relève du failsafe L1). SG-Ready/Batterie : repli ajouté à l'arrivée de
* leurs adaptateurs (3e/3f). Positionne \c m_degradedMode.
*
* \note Appelé une seule fois à la TRANSITION vers le mode dégradé. Ensuite \c update()
* suspend la planification, donc les consignes tiennent sans -émission par tick.
* \param now Instant courant (locks anti-rebond des bornes EV).
*/
void applyDegradedMode(const QDateTime &now);
RuleBasedScheduler *m_scheduler = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
QHash<QString, ILoadAdapter *> m_loadAdapters; //!< loadId → charge pilotée (relay-router | etmvariableload).
ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config).
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).
// --- L2 watchdog fraîcheur compteur (SAFETY.md §L2) ---
QTimer *m_meterWatchdog = nullptr; //!< Tick 30 s, indépendant des signaux compteur.
QDateTime m_lastMeterUpdate; //!< Horodatage du dernier powerBalanceChanged.
bool m_degradedMode = false; //!< Vrai si les consignes de repli L2 sont actives.
};

View File

@ -1,25 +0,0 @@
HEADERS += \
$$PWD/types/loadaction.h \
$$PWD/types/loaddescriptor.h \
$$PWD/types/surpluscontext.h \
$$PWD/types/plan.h \
$$PWD/types/loadconfig.h \
$$PWD/config/loadconfigstore.h \
$$PWD/adapters/iloadadapter.h \
$$PWD/scheduler/ischeduler.h \
$$PWD/adapters/evadapter.h \
$$PWD/adapters/sgreadyadapter.h \
$$PWD/adapters/etmvariableloadadapter.h \
$$PWD/adapters/relayrouter.h \
$$PWD/scheduler/rulebasedscheduler.h \
$$PWD/energyarbitrator.h \
SOURCES += \
$$PWD/types/loadconfig.cpp \
$$PWD/config/loadconfigstore.cpp \
$$PWD/adapters/evadapter.cpp \
$$PWD/adapters/sgreadyadapter.cpp \
$$PWD/adapters/etmvariableloadadapter.cpp \
$$PWD/adapters/relayrouter.cpp \
$$PWD/scheduler/rulebasedscheduler.cpp \
$$PWD/energyarbitrator.cpp \

View File

@ -1,31 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include "../types/surpluscontext.h"
#include "../types/plan.h"
/*!
* \brief Interface pure du planificateur d'énergie.
*
* Les implémentations concrètes héritent de QObject + IScheduler.
*
* \invariant getPlan() retourne IMMÉDIATEMENT (modèle cache, AGENTS invariant 5).
* SocketScheduler retourne son dernier plan en cache et recalcule en arrière-plan.
* \invariant getPlan() retourne TOUJOURS un Plan valide (isValid() == true).
* SocketScheduler embarque un RuleBasedScheduler en fallback jamais d'abstain
* qui remonterait à l'arbitre (AGENTS règle 6).
* \invariant Toute LoadAction du Plan retourné a \c reason non vide, en français.
*/
class IScheduler {
public:
virtual ~IScheduler() = default;
/*!
* \brief Calcule le plan d'optimisation à partir du contexte courant.
* \param ctx Contexte surplus (site, compteur, PV, batterie, charges, tarif).
* \return Plan avec au moins un Slot jamais Plan::isValid() == false.
* \note Retourne immédiatement depuis le cache ; le recalcul est asynchrone.
*/
virtual Plan getPlan(const SurplusContext &ctx) = 0;
};

View File

@ -1,289 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "rulebasedscheduler.h"
#include "../energyarbitrator.h"
#include "../../evcharger.h"
#include "../../types/chargingaction.h"
#include "../../types/charginginfo.h"
#include "plugininfo.h"
#include <QUuid>
#include <algorithm>
RuleBasedScheduler::RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent)
: QObject(parent)
, m_arbitrator(arbitrator)
{
}
Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
{
// Planification (même logique que l'amont — écrit dans m_chargingActions)
m_arbitrator->runSpotMarketPlanning(ctx.timestamp);
m_arbitrator->runSurplusPlanning(ctx.timestamp);
Slot slot;
slot.from = ctx.timestamp;
slot.to = ctx.timestamp.addSecs(60);
const auto &cas = m_arbitrator->scheduledActions();
const auto &evs = m_arbitrator->registeredEvChargers();
// Même priorité que adjustEvChargers() — iso-fonctionnel 3b
for (auto it = evs.constBegin(); it != evs.constEnd(); ++it) {
EvCharger *ev = it.value();
if (!ev->available() || !ev->pluggedIn())
continue;
const ChargingActions &actions = cas.value(ev);
LoadAction la;
if (actions.value(ChargingAction::ChargingActionIssuerTimeRequirement).chargingEnabled()) {
la = buildTimeRequirementAction(
ev, actions.value(ChargingAction::ChargingActionIssuerTimeRequirement));
} else if (actions.value(ChargingAction::ChargingActionIssuerSurplusCharging).chargingEnabled()) {
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSurplusCharging);
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = QStringLiteral("Surplus PV disponible — recharge solaire");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
} else if (actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging).chargingEnabled()) {
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging);
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Grid;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = QStringLiteral("Tarif aWATTar favorable — recharge heure creuse");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
} else {
const ChargingInfo::ChargingMode mode =
m_arbitrator->chargingInfo(ev->id()).chargingMode();
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
la = buildMinCurrentAction(ev);
} else {
la = buildIdleAction(ev);
}
}
slot.actions.append(la);
}
// ---- Waterfall ECS (charges non-EV à paliers) ---------------------------------
// Correction A — déduction EV unique : ctx.meter.exportW est la mesure BRUTE
// (invariant 8). Les consignes EV de CE cycle ne sont pas encore visibles au
// compteur ; on réserve donc leur puissance commandée non encore mesurée.
double evReservedW = 0;
for (const LoadAction &la : slot.actions) {
if (la.kind != LoadAction::Setpoint || !la.chargingEnabled)
continue;
EvCharger *ev = evs.value(ThingId(la.loadId));
const double measuredW = ev ? ev->currentPower() : 0.0;
evReservedW += qMax(0.0, la.estimatedPowerW - measuredW);
}
// Surplus net SIGNÉ : exportW importW = puissance compteur. Négatif en import →
// l'ECS déleste (budget < palier) au lieu de rester allumé sur le réseau. (Le maintien
// transitoire sous minOn est géré par le verrou de l'adaptateur, pas par le budget.)
double remainingSurplusW = (ctx.meter.exportW - ctx.meter.importW) - evReservedW;
// Charges pilotables non-EV triées par priorité ASCENDANTE : rang 1 = premier servi
// (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang). Le budget de surplus est
// UNIQUE et cascade à travers TOUTES ces charges par priorité :
// - etmvariableload (ECS/routeur, kind Setpoint W → buildSetpointAction, règle d'arrondi §5) ;
// - sg-ready (PAC, kind State → buildSgReadyStateAction, mapping sémantique).
// Charges en WATTS (Setpoint) : etmvariableload (continu) ET relay-router (rév. 3, combinaison
// de relais dérivée côté routeur). Charge à ÉTATS : sg-ready (PAC). Le scheduler distingue
// Setpoint vs State (nature de l'action), PAS la classe concrète d'adaptateur (frontière rév. 3).
QList<LoadContext> nonEvLoads;
for (const LoadContext &lc : ctx.loads) {
if (lc.adapter == QStringLiteral("etmvariableload") || lc.adapter == QStringLiteral("relay-router")
|| lc.adapter == QStringLiteral("sg-ready"))
nonEvLoads.append(lc);
}
std::sort(nonEvLoads.begin(), nonEvLoads.end(),
[](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; });
for (const LoadContext &lc : nonEvLoads) {
if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW));
else
slot.actions.append(buildSetpointAction(lc, remainingSurplusW)); // etmvariableload + relay-router
}
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
Plan plan;
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
plan.strategy = QStringLiteral("rule-based");
plan.timeSlots.append(slot);
return plan;
}
LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
const ChargingAction &ca) const
{
// Le courant final est affiné par adjustEvChargers() (allowance root-meter).
// En 3b on log la valeur brute de la planification — iso-fonctionnel.
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Grid;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = QStringLiteral("Deadline VE approchante — recharge prioritaire");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
return la;
}
LoadAction RuleBasedScheduler::buildMinCurrentAction(EvCharger *ev) const
{
const uint minA = qMax(EcoMinChargingCurrent, ev->maxChargingCurrentMinValue());
const uint phases = ev->phaseCount();
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = true;
la.currentA = minA;
la.phaseCount = phases;
la.reason = QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)");
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
return la;
}
LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const
{
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = false;
la.currentA = 0;
la.phaseCount = 0;
la.reason = QStringLiteral("Aucun surplus disponible — recharge suspendue");
la.estimatedPowerW = 0;
return la;
}
LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
double &remainingSurplusW) const
{
// Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle
// est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir.
// PAS de relecture post-setpoint (invariant 8 : aucune boucle de feedback).
const double budgetW = remainingSurplusW + lc.telemetry.currentPowerW;
const QList<int> &levels = lc.declared.powerLevels;
const bool dynamic = levels.isEmpty();
double setpointW;
if (dynamic) {
// Dynamic (routeur PV / triac) : modulation continue bornée au plafond déclaré.
const double maxW = lc.declared.maxPowerW > 0 ? lc.declared.maxPowerW : budgetW;
setpointW = qBound(0.0, budgetW, maxW);
} else {
// Fixed : plus haut palier déclaré ≤ budget (levels triés croissants, 0 inclus →
// garantit une consigne valide même budget ≤ 0). Granularité connue → zéro cycle de retard.
int chosen = 0;
for (int l : levels) {
if (l <= budgetW) chosen = l;
else break;
}
setpointW = chosen;
}
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.powerW = setpointW;
la.estimatedPowerW = setpointW;
if (setpointW > 0)
la.reason = QStringLiteral("Surplus PV %1 W — %2 consigne %3 W%4")
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(setpointW))
.arg(dynamic ? QString() : QStringLiteral(" (palier)"));
else
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 à 0 W")
.arg(qRound(budgetW)).arg(lc.label);
// Résidu : budget consigne engagée → charge suivante de la priorité (même cycle).
remainingSurplusW = budgetW - setpointW;
return la;
}
LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
double &remainingSurplusW) const
{
const int currentState = lc.telemetry.state;
const double p3 = lc.declared.estimatedPowerW.value(3, 0.0);
const double p4 = lc.declared.estimatedPowerW.value(4, 0.0);
// Recrédit (correction B) : la puissance ALLOUÉE de l'état courant (déclaré, 0 pour 1/2)
// revient au budget — comme l'ECS. Base : "quel surplus si la PAC n'était pas pilotée ?"
const double allocatedNowW = lc.declared.estimatedPowerW.value(currentState, 0.0);
const double budgetW = remainingSurplusW + allocatedNowW;
// Mapping SÉMANTIQUE (pas "le plus haut qui rentre") avec hystérésis d'état anti-oscillation :
// monter en 4 si budget ≥ P4×1,2 ; rester en 4 tant que budget ≥ P4×1,0 (zone morte) ;
// sinon recommandation (≥ P3) ; sinon normal (état 2, mains off).
// État 1 (effacement) jamais déclenché par le surplus seul — déféré (signal tarif/réseau).
constexpr double kForceMargin = 1.2; // hystérésis d'entrée en état 4
int targetState;
if (p4 > 0.0 && budgetW >= p4 * kForceMargin)
targetState = 4;
else if (currentState == 4 && p4 > 0.0 && budgetW >= p4) // zone morte : reste forcé
targetState = 4;
else if (p3 > 0.0 && budgetW >= p3)
targetState = 3;
else
targetState = 2;
// Clamp lock-aware (fenêtre minStateHold, calculée au MÊME ctx.timestamp).
const int loW = lc.telemetry.minState > 0 ? lc.telemetry.minState : 2;
const int hiW = lc.telemetry.maxState > 0 ? lc.telemetry.maxState : 2;
int bestState = qBound(qMin(loW, hiW), targetState, qMax(loW, hiW));
// Sécurité : ne jamais commander un état non déclaré (snap au plus haut déclaré ≤ cible).
if (!lc.declared.states.contains(bestState)) {
int snapped = lc.declared.states.isEmpty() ? 2 : lc.declared.states.first();
for (int s : lc.declared.states)
if (s <= bestState && s > snapped) snapped = s;
bestState = snapped;
}
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::State;
la.funding = LoadAction::Surplus;
la.state = bestState;
la.estimatedPowerW = lc.declared.estimatedPowerW.value(bestState, 0.0);
if (bestState != targetState)
la.reason = QStringLiteral("Verrou minStateHold — %1 maintenue état %2 (court-cycling PAC)")
.arg(lc.label).arg(bestState);
else if (bestState == 4)
la.reason = QStringLiteral("Surplus abondant %1 W — %2 forcée (état 4, ~%3 W)")
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(p4));
else if (bestState == 3)
la.reason = QStringLiteral("Surplus PV %1 W — %2 recommandée (état 3, ~%3 W)")
.arg(qRound(budgetW)).arg(lc.label).arg(qRound(p3));
else
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 en normal (état 2, non pilotée)")
.arg(qRound(budgetW)).arg(lc.label);
// Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0).
remainingSurplusW = budgetW - la.estimatedPowerW;
return la;
}

View File

@ -1,120 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include "ischeduler.h"
class EvCharger;
class ChargingAction;
class EnergyArbitrator;
/*!
* \brief Planificateur règles GPL : EV (proxy amont) + waterfall surplus SG-Ready (PAC).
*
* \c getPlan() produit un plan à 1 créneau en DEUX temps :
*
* 1. **EV proxy amont (beta, jusqu'à 3g)** : délègue la planification EV à
* \c planSurplusCharging() / \c planSpotMarketCharging() héritées de SmartChargingManager,
* relit \c m_chargingActions et les reformate en LoadAction(Setpoint) annotées d'un
* \c reason français. Le dispatch EV réel reste dans \c adjustEvChargers() amont.
*
* 2. **Waterfall non-EV** : un budget de surplus UNIQUE net SIGNÉ
* \c (exportW importW) evReservedW cascade par **priorité ASC** (rang, 1 = premier
* servi). En T2, seule la PAC \c sg-ready (\c buildSgReadyStateAction, kind State) y est
* traitée : anti-clignotement par **recrédit** de la conso allouée, **clamp lock-aware**
* \c minState/maxState (verrou \c minStateHold, protection court-cycling compresseur) calculé
* au MÊME \c ctx.timestamp que l'exécution (cf. \c ILoadAdapter). Ces LoadAction sont
* RÉELLEMENT dispatchées par \c EnergyArbitrator::applyActionsToAdapters().
*
* Les charges \c etmvariableload (ECS/routeur, kind Setpoint W) rejoindront ce waterfall avec
* la règle d'arrondi (contrat rév. 2 §5) en **T3**. À partir de **3g**, l'EV rejoint aussi le
* waterfall unifié (toutes charges classables ensemble).
*
* \invariant getPlan() retourne IMMÉDIATEMENT (AGENTS invariant 5) et toujours un Plan valide.
* \invariant Toute LoadAction a un \c reason non vide, en français.
* \invariant EV (étape 1) : priorité Deadline VE > Surplus PV > aWATTar > Min courant > Idle
* (iso-fonctionnel amont 3b). Non-EV (étape 2) : ordre = \c priority croissant.
*/
class RuleBasedScheduler : public QObject, public IScheduler
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param arbitrator Arbitre propriétaire fournit l'accès à la planification et à l'état.
* \param parent Propriétaire Qt.
*/
explicit RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent = nullptr);
/*!
* \brief Retourne le plan pour le slot courant (EV proxy + waterfall non-EV).
*
* Étape 1 (EV) : \c runSpotMarketPlanning() + \c runSurplusPlanning() puis reformatage
* de \c scheduledActions() en LoadAction(Setpoint) log [Arbitre], dispatch amont.
* Étape 2 (non-EV) : waterfall surplus sur la PAC \c sg-ready triée par priorité
* LoadAction(State) réellement dispatchées (etmvariableload ECS/routeur arrive en T3).
*
* \param ctx SurplusContext courant. Utilisé : \c ctx.timestamp (temps de cycle / verrous),
* \c ctx.meter (surplus net signé), \c ctx.loads (déclarés, télémétrie, fenêtres de verrou).
* \return Plan à 1 créneau couvrant \c ctx.timestamp + 60 s.
*/
Plan getPlan(const SurplusContext &ctx) override;
private:
/*!
* \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement).
* \param ev EvCharger concerné.
* \param ca ChargingAction planifiée (courant et phases déjà calculés par planSurplusCharging).
* \return LoadAction avec funding=Grid et reason "Deadline VE".
*/
LoadAction buildTimeRequirementAction(EvCharger *ev, const ChargingAction &ca) const;
/*!
* \brief Construit un LoadAction "courant minimum" pour les modes EcoMin.
* \param ev EvCharger concerné.
* \return LoadAction avec funding=Surplus, chargingEnabled=true, currentA=min.
*/
LoadAction buildMinCurrentAction(EvCharger *ev) const;
/*!
* \brief Construit un LoadAction "idle" (recharge désactivée, aucun surplus).
* \param ev EvCharger concerné.
* \return LoadAction avec chargingEnabled=false et reason appropriée.
*/
LoadAction buildIdleAction(EvCharger *ev) const;
/*!
* \brief Construit un LoadAction \c Setpoint (W) pour une charge \c etmvariableload
* (ECS résistif / routeur PV) par la règle d'arrondi du contrat rév. 2 §5.
*
* Recrédit anti-clignotement (Correction B / contrat §5) : le budget local =
* \c remainingSurplusW + \c lc.telemetry.currentPowerW (conso de DÉBUT de cycle, déjà
* soustraite de l'export mesuré JAMAIS une relecture post-setpoint, invariant 8). Puis :
* - **fixed** (\c powerLevels non vide) : setpoint = plus haut palier déclaré budget ;
* - **dynamic** (\c powerLevels vide) : setpoint = \c clamp(budget, 0, maxPowerW).
* Le résidu \c budget setpoint repart vers la charge suivante de la priorité (même cycle).
*
* \param lc Charge \c etmvariableload du SurplusContext.
* \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la suite.
* \return LoadAction kind=Setpoint, funding=Surplus, \c powerW = consigne, \c reason non vide.
*/
LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW) const;
/*!
* \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus.
*
* 4 états normés (qualitatifs, pas des paliers) : surplus abondant stable 4 (forcé,
* hystérésis P4×1,2 entrée / P4×1,0 sortie) ; surplus durable 3 (recommandation, P3) ;
* sinon 2 (normal, mains off). L'état 1 (effacement) n'est PAS déclenché par le surplus
* seul (déféré : signal tarif/réseau). Recrédit (correction B) sur la puissance allouée
* (déclaré, 0 pour 1/2). Clamp lock-aware via \c minState/maxState (court-cycling PAC).
*
* \param lc Charge SG-Ready (adapter == "sg-ready") du SurplusContext.
* \param[in,out] remainingSurplusW Budget de surplus restant (W), mis à jour pour la suite.
* \return LoadAction kind=State, funding=Surplus, \c reason français non vide.
*/
LoadAction buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW) const;
EnergyArbitrator *m_arbitrator;
};

View File

@ -1,71 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QString>
/*!
* \brief Action typée émise par l'arbitre vers un ILoadAdapter.
*
* Noms de champs identiques à OPTIMIZER_PROTOCOL.md §6 (fait autorité).
* \c funding est interne à l'arbitre absent du JSON protocole socket.
*
* \invariant \c reason doit être non vide et en français (AGENTS invariant 7).
* Un adaptateur doit rejeter toute action avec \c reason vide.
* \invariant Pour kind == Setpoint / evcharger : seuls \c chargingEnabled,
* \c currentA, \c phaseCount sont significatifs.
*/
struct LoadAction {
/*! \brief Type d'action : consigne en watts (etmvariableload), état SG-Ready ou contrainte.
* \note Le kind \c Stage (index de palier ECS) a é RETIRÉ (contrat etmvariableload rév. 2
* §6) : l'ECS est piloté en watts via \c Setpoint, l'index de palier vit dans le thing. */
enum Kind { Setpoint, State, Constraint };
/*! \brief Financement interne : Surplus (PV) ou Grid (réseau). Non sérialisé. */
enum Funding { Surplus, Grid };
/*! \brief Source d'énergie pour Setpoint batterie : "solar" ou "grid". */
enum Source { Solar, GridSource };
/*! \brief Permission de charge/décharge pour Constraint batterie. */
enum Permission { Allow, Forbid };
QString loadId; //!< ThingId de la charge cible (string).
Kind kind = Setpoint;
Funding funding = Surplus;
// --- Setpoint evcharger ---
bool chargingEnabled = false;
double currentA = 0; //!< Courant consigne (A), écrêté par l'adaptateur.
uint phaseCount = 0; //!< Nombre de phases (1 ou 3, 0 = inchangé).
// --- Setpoint battery ---
double powerW = 0;
Source source = Solar;
// --- State sg-ready ---
int state = 0; //!< État SG-Ready (1-4).
// --- Constraint battery ---
Permission charge = Allow;
Permission discharge = Allow;
/*!
* \brief Motif de la décision, non vide, en français.
* Obligatoire (invariant 7). L'adaptateur rejette silencieusement si vide.
*/
QString reason;
/*!
* \brief Puissance estimée (W) hint pour la comptabilité budget de l'arbitre.
* Rempli par le scheduler ; peut être 0 si inconnu.
*/
double estimatedPowerW = 0;
/*!
* \brief Forçage sécurité bypasse les verrous anti-rebond (minOn/minOff).
*
* Positionné à \c true uniquement par \c applyDegradedMode() (L2 watchdog)
* et les contraintes de sécurité. Les adaptateurs doivent appliquer l'action
* immédiatement sans vérifier les verrous temporels.
* \warning Réservé à la sécurité. Ne jamais mettre à \c true dans un scheduler.
*/
bool force = false;
};

View File

@ -1,181 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "loadconfig.h"
#include <algorithm>
// ---- LoadConfigNeeds -------------------------------------------------------
QVariantMap LoadConfigNeeds::toMap() const
{
QVariantMap m;
if (!m_dailyDeadline.isEmpty())
m.insert("dailyDeadline", m_dailyDeadline);
if (m_minEnergyWhPerDay > 0)
m.insert("minEnergyWhPerDay", m_minEnergyWhPerDay);
return m;
}
LoadConfigNeeds LoadConfigNeeds::fromMap(const QVariantMap &map)
{
LoadConfigNeeds n;
n.m_dailyDeadline = map.value("dailyDeadline").toString();
n.m_minEnergyWhPerDay = map.value("minEnergyWhPerDay").toInt();
return n;
}
// ---- LoadConfigRelay -------------------------------------------------------
QVariantMap LoadConfigRelay::toMap() const
{
QVariantMap m;
m.insert("thingId", thingId);
m.insert("powerW", powerW);
return m;
}
LoadConfigRelay LoadConfigRelay::fromMap(const QVariantMap &map)
{
LoadConfigRelay r;
r.thingId = map.value("thingId").toString();
r.powerW = map.value("powerW").toInt();
return r;
}
// ---- LoadConfig ------------------------------------------------------------
QVariantList LoadConfig::relays() const
{
QVariantList list;
for (const LoadConfigRelay &r : m_relays)
list.append(r.toMap());
return list;
}
void LoadConfig::setRelays(const QVariantList &v)
{
m_relays.clear();
for (const QVariant &item : v)
m_relays.append(LoadConfigRelay::fromMap(item.toMap()));
}
QVariantList LoadConfig::powerLevels() const
{
QVariantList list;
for (int l : m_powerLevels)
list.append(l);
return list;
}
void LoadConfig::setPowerLevels(const QVariantList &v)
{
m_powerLevels.clear();
for (const QVariant &item : v)
m_powerLevels.append(item.toInt());
// Tri croissant + déduplication (contrat §6 : l'energymanager re-trie par sécurité).
std::sort(m_powerLevels.begin(), m_powerLevels.end());
m_powerLevels.erase(std::unique(m_powerLevels.begin(), m_powerLevels.end()), m_powerLevels.end());
}
bool LoadConfig::isValid(QString *error) const
{
auto fail = [&](const QString &msg) { if (error) *error = msg; return false; };
// Validation CONDITIONNELLE au type (contrat rév. 3) — pas "tous champs requis".
// Les deux formes (relay-router / etmvariableload) sont mutuellement exclusives ; le schéma
// JSON-RPC SET marque TOUS les champs spécifiques en optionnel, c'est ICI qu'on tranche.
if (m_id.isEmpty())
return fail(QStringLiteral("id de charge vide"));
if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic"))
return fail(QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode));
if (m_adapter == QStringLiteral("relay-router")) {
// Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici).
if (m_mode != QStringLiteral("fixed"))
return fail(QStringLiteral("relay-router : mode doit être \"fixed\""));
if (m_relays.isEmpty())
return fail(QStringLiteral("relay-router : relays[] requis (non vide)"));
for (const LoadConfigRelay &r : m_relays) {
if (r.thingId.isEmpty())
return fail(QStringLiteral("relay-router : un relais a un thingId vide"));
if (r.powerW <= 0)
return fail(QStringLiteral("relay-router : relais %1 a powerW ≤ 0").arg(r.thingId));
}
if (m_minOnS < 0 || m_minOffS < 0)
return fail(QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0"));
} else if (m_adapter == QStringLiteral("etmvariableload")) {
// Cas thing unique : continu (dynamic) ou multipalier natif (fixed + powerLevels, legacy).
if (!m_relays.isEmpty())
return fail(QStringLiteral("etmvariableload : relays[] interdit (réservé à relay-router)"));
if (m_mode == QStringLiteral("fixed")) {
if (m_powerLevels.isEmpty())
return fail(QStringLiteral("etmvariableload fixed : powerLevels requis (non vide)"));
if (m_powerLevels.first() != 0)
return fail(QStringLiteral("etmvariableload fixed : powerLevels doit inclure 0"));
} else { // dynamic
if (m_maxPowerW <= 0)
return fail(QStringLiteral("etmvariableload dynamic : maxPowerW requis (> 0)"));
if (!m_powerLevels.isEmpty())
return fail(QStringLiteral("etmvariableload dynamic : powerLevels doit être absent"));
}
} else {
return fail(QStringLiteral("adapter inconnu \"%1\" (attendu \"relay-router\" ou \"etmvariableload\")").arg(m_adapter));
}
return true;
}
QVariantMap LoadConfig::toMap() const
{
QVariantMap m;
m.insert("id", m_id);
m.insert("label", m_label);
m.insert("adapter", m_adapter);
m.insert("mode", m_mode);
if (!m_powerLevels.isEmpty())
m.insert("powerLevels", powerLevels());
m.insert("maxPowerW", m_maxPowerW);
m.insert("priority", m_priority);
m.insert("enabled", m_enabled);
const QVariantMap needs = m_needs.toMap();
if (!needs.isEmpty())
m.insert("needs", needs);
// rév. 3 — relais (cas relay-router) : sérialisés seulement s'ils existent.
if (!m_relays.isEmpty()) {
m.insert("relays", relays());
m.insert("minOnS", m_minOnS);
m.insert("minOffS", m_minOffS);
}
return m;
}
LoadConfig LoadConfig::fromMap(const QVariantMap &map)
{
LoadConfig c;
c.m_id = map.value("id").toString();
c.m_label = map.value("label").toString();
c.m_adapter = map.value("adapter", QStringLiteral("etmvariableload")).toString();
c.m_mode = map.value("mode").toString();
c.setPowerLevels(map.value("powerLevels").toList()); // trie + déduplique
c.m_maxPowerW = map.value("maxPowerW").toInt();
c.m_priority = map.value("priority").toInt();
c.m_enabled = map.value("enabled", true).toBool();
c.m_needs = LoadConfigNeeds::fromMap(map.value("needs").toMap());
c.setRelays(map.value("relays").toList());
c.m_minOnS = map.value("minOnS").toInt();
c.m_minOffS = map.value("minOffS").toInt();
return c;
}
// ---- LoadConfigs -----------------------------------------------------------
QVariant LoadConfigs::get(int index) const
{
return QVariant::fromValue(at(index));
}
void LoadConfigs::put(const QVariant &variant)
{
append(variant.value<LoadConfig>());
}

View File

@ -1,159 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QString>
#include <QList>
#include <QVariant>
#include <QVariantList>
#include <QVariantMap>
/*!
* \brief Besoins énergétiques d'une charge pilotée (contrat etmvariableload §4 \c needs).
*
* Q_GADGET typé pour l'introspection JSON-RPC (champs self-describing côté app).
*/
class LoadConfigNeeds
{
Q_GADGET
Q_PROPERTY(QString dailyDeadline READ dailyDeadline WRITE setDailyDeadline)
Q_PROPERTY(int minEnergyWhPerDay READ minEnergyWhPerDay WRITE setMinEnergyWhPerDay)
public:
LoadConfigNeeds() {}
QString dailyDeadline() const { return m_dailyDeadline; }
void setDailyDeadline(const QString &v) { m_dailyDeadline = v; }
int minEnergyWhPerDay() const { return m_minEnergyWhPerDay; }
void setMinEnergyWhPerDay(int v) { m_minEnergyWhPerDay = v; }
QVariantMap toMap() const;
static LoadConfigNeeds fromMap(const QVariantMap &map);
private:
QString m_dailyDeadline; //!< "HH:MM" (vide = pas d'échéance quotidienne).
int m_minEnergyWhPerDay = 0;
};
Q_DECLARE_METATYPE(LoadConfigNeeds)
/*!
* \brief Un relais d'une charge \c relay-router (contrat rév. 3 §4) : un thing \c power + sa
* puissance nominale (W). Imbriqué dans \c LoadConfig.relays vit côté ROUTEUR, ne franchit
* JAMAIS la frontière vers l'optimiseur (qui ne voit que des watts dérivés).
*/
struct LoadConfigRelay
{
QString thingId; //!< ThingId du thing implémentant l'interface \c power.
int powerW = 0; //!< Puissance nominale de ce relais (W).
QVariantMap toMap() const;
static LoadConfigRelay fromMap(const QVariantMap &map);
};
/*!
* \brief Déclaration de config d'une charge pilotée contrat etmvariableload §4.
*
* \b Source de vérité inter-repos : la forme de \c toMap()/\c fromMap() (et donc des
* Q_PROPERTY) est **mot pour mot** le \c LoadDescriptor §4 émis par l'app
* (\c id, \c label, \c adapter, \c mode, \c powerLevels, \c maxPowerW, \c priority,
* \c enabled, \c needs). Ne PAS diverger c'est ce que reçoit \c SetLoadConfig.
*
* Distinct de \c LoadDescriptor (etm/types) qui mire OPTIMIZER_PROTOCOL §5 (sans
* \c enabled / \c mode). Ce type est la config persistée, pas le contexte d'arbitrage.
*/
class LoadConfig
{
Q_GADGET
Q_PROPERTY(QString id READ id WRITE setId)
Q_PROPERTY(QString label READ label WRITE setLabel)
Q_PROPERTY(QString adapter READ adapter WRITE setAdapter)
Q_PROPERTY(QString mode READ mode WRITE setMode)
Q_PROPERTY(QVariantList powerLevels READ powerLevels WRITE setPowerLevels)
Q_PROPERTY(int maxPowerW READ maxPowerW WRITE setMaxPowerW)
Q_PROPERTY(int priority READ priority WRITE setPriority)
Q_PROPERTY(bool enabled READ enabled WRITE setEnabled)
Q_PROPERTY(LoadConfigNeeds needs READ needs WRITE setNeeds)
// rév. 3 — cas relay-router (la combinatoire vit côté routeur, jamais chez l'optimiseur) :
Q_PROPERTY(QVariantList relays READ relays WRITE setRelays) //!< [{thingId, powerW}]
Q_PROPERTY(int minOnS READ minOnS WRITE setMinOnS)
Q_PROPERTY(int minOffS READ minOffS WRITE setMinOffS)
public:
LoadConfig() {}
QString id() const { return m_id; }
void setId(const QString &v) { m_id = v; }
QString label() const { return m_label; }
void setLabel(const QString &v) { m_label = v; }
QString adapter() const { return m_adapter; }
void setAdapter(const QString &v) { m_adapter = v; }
QString mode() const { return m_mode; }
void setMode(const QString &v) { m_mode = v; }
//! powerLevels en QVariantList (introspection) ; trié + dédupliqué à l'écriture.
QVariantList powerLevels() const;
void setPowerLevels(const QVariantList &v);
//! Accès typé pour la construction de l'adaptateur.
QList<int> powerLevelsInt() const { return m_powerLevels; }
int maxPowerW() const { return m_maxPowerW; }
void setMaxPowerW(int v) { m_maxPowerW = v; }
int priority() const { return m_priority; }
void setPriority(int v) { m_priority = v; }
bool enabled() const { return m_enabled; }
void setEnabled(bool v) { m_enabled = v; }
LoadConfigNeeds needs() const { return m_needs; }
void setNeeds(const LoadConfigNeeds &v) { m_needs = v; }
//! relays en QVariantList (introspection / sérialisation) ; typé via relaysList().
QVariantList relays() const;
void setRelays(const QVariantList &v);
QList<LoadConfigRelay> relaysList() const { return m_relays; }
int minOnS() const { return m_minOnS; }
void setMinOnS(int v) { m_minOnS = v; }
int minOffS() const { return m_minOffS; }
void setMinOffS(int v) { m_minOffS = v; }
//! Vrai si la charge est un routeur de relais (rév. 3 : combinatoire watts→relais côté routeur).
bool isRelayRouter() const { return m_adapter == QStringLiteral("relay-router"); }
//! Vrai si *dynamic* (modulation continue). Sinon *fixed*.
bool isDynamic() const { return m_mode == QStringLiteral("dynamic"); }
/*!
* \brief Valide la cohérence (contrat §4/§6) : adapter, id, mode, paliers/plafond.
* \param[out] error Message FR si invalide.
* \return true si la config est exploitable.
*/
bool isValid(QString *error = nullptr) const;
QVariantMap toMap() const;
static LoadConfig fromMap(const QVariantMap &map);
private:
QString m_id;
QString m_label;
QString m_adapter = QStringLiteral("etmvariableload");
QString m_mode;
QList<int> m_powerLevels; //!< Trié croissant, 0 inclus en *fixed* ; vide en *dynamic*.
int m_maxPowerW = 0;
int m_priority = 0;
bool m_enabled = true;
LoadConfigNeeds m_needs;
QList<LoadConfigRelay> m_relays; //!< rév. 3 — relais du routeur (cas relay-router).
int m_minOnS = 0;
int m_minOffS = 0;
};
Q_DECLARE_METATYPE(LoadConfig)
/*! \brief Liste de LoadConfig — gadget pour registerObject<LoadConfig, LoadConfigs>(). */
class LoadConfigs : public QList<LoadConfig>
{
Q_GADGET
Q_PROPERTY(int count READ count)
public:
LoadConfigs() {}
LoadConfigs(const QList<LoadConfig> &other) : QList(other) {}
Q_INVOKABLE QVariant get(int index) const;
Q_INVOKABLE void put(const QVariant &variant);
};
Q_DECLARE_METATYPE(LoadConfigs)

View File

@ -1,92 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QList>
#include <QHash>
#include <QString>
#include "loadaction.h"
/*!
* \brief Capacités déclarées par l'installateur (plaque signalétique, câblage).
* Correspond à OPTIMIZER_PROTOCOL.md §5 loads[].declared (noms identiques).
* \note Les champs inutilisés pour un type d'adaptateur restent à leur valeur par défaut.
*/
struct LoadDeclared {
// --- evcharger ---
double minA = 0; //!< Courant minimum (A).
double maxA = 0; //!< Courant maximum (A).
int phases = 0; //!< Nombre de phases disponibles (1 ou 3).
// --- etmvariableload (charge à puissance pilotable : EV / ECS résistif / routeur PV) ---
//! Paliers atteignables en W (contrat etmvariableload §3), triés croissants, \c 0 inclus.
//! Présent en mode *fixed*. Vide ⇒ modulation continue (*dynamic*, borne \c maxPowerW).
//! C'est la DÉCLARATION installateur (référence de planification, contrat §5) — distincte
//! de la conso runtime \c currentPowerW.
QList<int> powerLevels;
//! Plafond physique de la charge (W) — contrat etmvariableload §2 \c maxPowerW. REQUIS en
//! *dynamic* ; sert aussi de plafond en *fixed*.
double maxPowerW = 0;
// --- battery ---
double maxChargeW = 0; //!< Puissance max de charge (W).
double maxDischargeW = 0; //!< Puissance max de décharge (W).
double capacityWh = 0; //!< Capacité totale (Wh).
int reserveSocPercent = 0; //!< SOC de réserve (%) — non déchargeable.
// --- sg-ready (PAC) ---
//! États supportés (toujours 1-4 ; déclaré pour symétrie avec le protocole §5).
QList<int> states;
//! Puissance estimée (W) par état, DÉCLARÉE installateur (approximative, cas
//! \c declared du protocole §5) : ex. {3: 1800, 4: 2600}. Sert au budget, n'est PAS
//! une consigne exacte. États 1 (blocage) et 2 (normal) ≈ 0 du point de vue allocation
//! surplus (la conso autonome de l'état 2 est déjà au compteur — invariant 8).
QHash<int, double> estimatedPowerW;
};
/*!
* \brief Contraintes anti-rebond et lock temporel d'une charge.
* Correspond à OPTIMIZER_PROTOCOL.md §5 loads[].limits.
*/
struct LoadLimits {
int minOnS = 0; //!< Durée minimale ON (s).
int minOffS = 0; //!< Durée minimale OFF (s).
int chargingEnabledLockS = 0; //!< Lock on/off (s) — evcharger.
int currentChangeLockS = 0; //!< Lock changement courant (s) — evcharger.
int minStateHoldS = 0; //!< Durée minimale maintien état (s) — sg-ready.
};
/*!
* \brief Besoins énergétiques déclarés par l'utilisateur pour une charge.
* Correspond à OPTIMIZER_PROTOCOL.md §5 loads[].needs.
*/
struct LoadNeeds {
int targetSocPercent = 0; //!< SOC cible (%) — EV / batterie.
QDateTime deadline; //!< Échéance absolue de recharge.
QString dailyDeadline; //!< Heure limite quotidienne, format "HH:MM".
int minEnergyWhPerDay = 0; //!< Énergie minimale par jour (Wh).
};
/*!
* \brief Description statique complète d'une charge, exposée par ILoadAdapter.
*
* L'arbitre lit ce descripteur une fois par cycle pour construire le SurplusContext.
* Les valeurs doivent refléter la configuration matérielle réelle (non les setpoints).
*
* \note \c priority : rang dans la liste ordonnée du client (OPTIMIZER_PROTOCOL §5 +
* annexe C). Valeur plus BASSE = servi en premier (rang 1 = premier servi).
* Les promotions conditionnelles (deadline, Tempo ROUGE) sont gérées par le scheduler,
* pas par un poids numérique.
*/
struct LoadDescriptor {
QString id; //!< ThingId de la charge (string).
QString label; //!< Nom lisible (affiché dans les logs).
//! Type d'adaptateur : "evcharger"|"etmvariableload"|"sg-ready"|"battery".
QString adapter;
int priority = 0;
LoadDeclared declared;
LoadLimits limits;
LoadNeeds needs;
QList<LoadAction::Kind> supportedKinds; //!< Kinds acceptés par applyAction().
};

View File

@ -1,54 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QList>
#include <QString>
#include "loadaction.h"
// Structures miroirs de OPTIMIZER_PROTOCOL.md §6 (noms de champs identiques).
/*!
* \brief Créneau d'un plan contient les LoadAction à appliquer pendant [from, to[.
*
* \invariant Les actions sont ordonnées par priorité croissante (rang 1 = premier servi,
* OPTIMIZER_PROTOCOL §5 + annexe C).
* \invariant Un Slot vide (actions vide) est valide signifie "aucune action ce créneau".
*/
struct Slot {
QDateTime from;
QDateTime to;
QList<LoadAction> actions;
};
/*!
* \brief Plan d'optimisation retourné par IScheduler::getPlan().
*
* \invariant \c isValid() == true après tout appel à getPlan() (invariant IScheduler).
* \invariant \c planId est unique par plan généré (UUID ou compteur).
*/
struct Plan {
QString planId; //!< Identifiant unique (UUID string).
QString strategy; //!< "rule-based" | "socket" | "socket-fallback".
//! Créneaux du plan. Nommé \c timeSlots (pas \c slots, mot-clé Qt).
//! Sérialisation JSON : sous le nom "slots" — OPTIMIZER_PROTOCOL.md §6
//! fait autorité sur le nom de fil, le renommage est purement interne C++.
QList<Slot> timeSlots;
/*!
* \brief Retourne le Slot couvrant \p dt.
* \param dt Instant à couvrir.
* \return Slot dont from dt < to, ou Slot vide (from/to invalides) si aucun ne correspond.
*/
Slot slotCovering(const QDateTime &dt) const {
for (const Slot &s : timeSlots) {
if (dt >= s.from && dt < s.to)
return s;
}
return {};
}
/*! \brief Vrai si le plan contient au moins un Slot. */
bool isValid() const { return !timeSlots.isEmpty(); }
};

View File

@ -1,116 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QList>
#include <QString>
#include "loaddescriptor.h"
// Structures miroirs de OPTIMIZER_PROTOCOL.md §5 (noms de champs identiques).
/*! \brief Paramètres fixes du site (contrat réseau, limite par phase). */
struct SurplusSite {
double contractedPowerW = 0; //!< Puissance souscrite (W).
QList<double> phaseLimitA; //!< Limite par phase (A) : [63, 63, 63].
};
/*! \brief Mesures du compteur principal (rootmeter). */
struct SurplusMeter {
double importW = 0; //!< Puissance importée depuis le réseau (W, ≥ 0).
double exportW = 0; //!< Puissance exportée vers le réseau (W, ≥ 0).
QList<double> perPhaseA; //!< Courant par phase (A).
};
/*! \brief Production PV courante. */
struct SurplusPv {
double currentW = 0; //!< Puissance PV mesurée (W, ≥ 0).
};
/*! \brief État courant du système de stockage (batterie). */
struct SurplusBattery {
bool present = false;
double socPercent = 0;
double powerW = 0; //!< Positif = charge, négatif = décharge.
double capacityWh = 0;
int reserveSocPercent = 0;
double maxChargeW = 0;
double maxDischargeW = 0;
};
/*! \brief Entrée tarifaire (créneau HP/HC ou spot market). */
struct TariffEntry {
QDateTime from;
QString label; //!< "HP", "HC", "Tempo-Rouge", ...
double priceCtkWh = 0; //!< Prix en ct€/kWh.
};
/*! \brief Contexte tarifaire : créneau courant + prochains créneaux. */
struct SurplusTariff {
QString provider;
TariffEntry current;
QList<TariffEntry> next; //!< Créneaux suivants, ordre chronologique.
};
/*! \brief Télémétrie d'une charge dans le contexte §5 loads[].telemetry. */
struct LoadContextTelemetry {
double currentPowerW = 0; //!< Puissance mesurée (W).
// --- evcharger ---
bool pluggedIn = false;
bool charging = false;
double sessionWh = 0; //!< Énergie chargée dans la session courante (Wh).
// --- etmvariableload (EV/ECS/routeur) : pas de fenêtre de verrou côté moteur —
// l'anti-rebond (minOn/minOff) vit dans le thing (contrat rév. 2 §3). Seul
// currentPowerW (ci-dessus) sert au résidu de la règle d'arrondi (contrat §5).
// --- sg-ready ---
int state = 0;
//! Fenêtre d'états autorisée MAINTENANT par le verrou minStateHold (protection
//! court-cycling PAC) : gel total (minState == maxState == state) si non écoulé,
//! sinon [1, 4]. Même mécanique que minStage/maxStage de l'ECS, source = temps de cycle.
int minState = 0;
int maxState = 0;
// --- battery / electricvehicle ---
double socPercent = 0;
QDateTime lastSwitch; //!< Dernier changement d'état.
};
/*! \brief Données apprises par l'optimiseur §8 (renvoyées pour persistance). */
struct LoadLearned {
double dailyEnergyWh = 0;
//! Confiance 01 ; < 0.7 = "profil en apprentissage".
double confidence = 0.0;
};
/*!
* \brief Entrée loads[] du SurplusContext envoyé au scheduler.
* Construit par l'arbitre depuis ILoadAdapter::descriptor() + toLoadContext().
*/
struct LoadContext {
QString id;
QString adapter; //!< "evcharger"|"etmvariableload"|"sg-ready"|"battery".
QString label;
int priority = 0;
LoadDeclared declared;
LoadLearned learned;
LoadContextTelemetry telemetry;
LoadNeeds needs;
LoadLimits limits;
};
/*!
* \brief Contexte complet transmis au scheduler à chaque cycle (OPTIMIZER_PROTOCOL §5).
*
* \invariant \c timestamp correspond à l'instant du début du cycle.
* \invariant \c pv.currentW est la PV mesurée brute JAMAIS le net après pilotage
* (AGENTS invariant 8 : pas de boucle de feedback).
*/
struct SurplusContext {
QDateTime timestamp;
SurplusSite site;
SurplusMeter meter;
SurplusPv pv;
SurplusBattery battery;
QList<LoadContext> loads;
SurplusTariff tariff;
// forecast : opaque, transmis tel quel si présent — réservé V2
};

View File

@ -26,19 +26,16 @@
#include "types/charginginfo.h"
#include "smartchargingmanager.h"
#include "spotmarket/spotmarketmanager.h"
#include "etm/types/loadconfig.h"
#include "etm/config/loadconfigstore.h"
#include <energymanager.h>
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore, QObject *parent):
NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, QObject *parent):
JsonHandler{parent},
m_spotMarketManager{spotMarketManager},
m_smartChargingManager{smartChargingManager},
m_loadConfigStore{loadConfigStore}
m_smartChargingManager{smartChargingManager}
{
registerEnum<ChargingInfo::ChargingMode>();
@ -50,8 +47,6 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
registerObject<ScoreEntry, ScoreEntries>();
registerObject<ChargingAction>();
registerObject<ChargingSchedule, ChargingSchedules>();
registerObject<LoadConfigNeeds>();
registerObject<LoadConfig, LoadConfigs>();
QVariantMap params, returns;
QString description;
@ -144,45 +139,6 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
registerMethod("GetChargingSchedules", description, params, returns, Types::PermissionScopeControlThings);
// [ETM] Configuration des charges pilotées (interface etmvariableload, contrat §4).
params.clear(); returns.clear();
description = "Get the configured controllable loads (etmvariableload).";
returns.insert("loadConfigs", QVariantList() << objectRef<LoadConfig>());
registerMethod("GetLoadConfig", description, params, returns, Types::PermissionScopeControlThings);
params.clear(); returns.clear();
description = "Replace the full set of controllable load configs. Rejected as a whole "
"(energyError) if any entry is invalid; persisted and applied atomically.";
// Schéma inline (pas objectRef<LoadConfig>) : le contrat rév. 3 a DEUX formes exclusives
// (relay-router : relays[]+minOnS/minOffS — etmvariableload : powerLevels|maxPowerW).
// objectRef rendrait TOUS les champs requis → nymea rejetterait une des deux formes AVANT
// le handler. On marque donc TOUS les champs spécifiques "o:" ; la validation conditionnelle
// (par adapter/mode) vit dans LoadConfig::isValid(). Seuls id/label/adapter/mode/priority/
// enabled sont requis (présents dans les deux formes). GET reste typé objectRef<LoadConfig>.
QVariantMap needsItem;
needsItem.insert("o:dailyDeadline", enumValueName(String));
needsItem.insert("o:minEnergyWhPerDay", enumValueName(Uint));
QVariantMap relayItem;
relayItem.insert("thingId", enumValueName(String));
relayItem.insert("powerW", enumValueName(Uint));
QVariantMap loadItem;
loadItem.insert("id", enumValueName(String));
loadItem.insert("label", enumValueName(String));
loadItem.insert("adapter", enumValueName(String));
loadItem.insert("mode", enumValueName(String));
loadItem.insert("priority", enumValueName(Uint));
loadItem.insert("enabled", enumValueName(Bool));
loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); // etmvariableload fixed
loadItem.insert("o:maxPowerW", enumValueName(Uint)); // etmvariableload dynamic
loadItem.insert("o:needs", needsItem);
loadItem.insert("o:relays", QVariantList() << relayItem); // relay-router (rév. 3)
loadItem.insert("o:minOnS", enumValueName(Uint));
loadItem.insert("o:minOffS", enumValueName(Uint));
params.insert("loadConfigs", QVariantList() << loadItem);
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeControlThings);
// Notifications
params.clear();
description = "Emitted whenever the phase power limit configuration changes.";
@ -241,9 +197,6 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
params.clear();
description = "Emitted whenever the planed charging schedules have changed.";
params.insert("chargingSchedules", QVariantList() << objectRef<ChargingSchedule>());
// [ETM] degradedMode : true quand le watchdog L2 a basculé en repli (compteur muet).
// Optionnel (additif, rétro-compatible) ; émis aussi aux transitions du mode dégradé.
params.insert("o:degradedMode", enumValueName(Bool));
registerNotification("ChargingSchedulesChanged", description, params);
// Charing manager
@ -270,27 +223,9 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
schedules << pack<ChargingSchedule>(schedule);
}
params.insert("chargingSchedules", schedules);
params.insert("degradedMode", m_smartChargingManager->degradedMode()); // [ETM] L2
emit ChargingSchedulesChanged(params);
});
// [ETM] LoadConfigChanged : émise après toute persistance réussie (SetLoadConfig ou
// édition externe du fichier relue). Porte la liste complète à jour.
params.clear();
description = "Emitted whenever the controllable load configuration changed.";
params.insert("loadConfigs", QVariantList() << objectRef<LoadConfig>());
registerNotification("LoadConfigChanged", description, params);
if (m_loadConfigStore) {
connect(m_loadConfigStore, &LoadConfigStore::changed, this, [this](){
QVariantMap params;
QVariantList list;
foreach (const LoadConfig &c, m_loadConfigStore->configs())
list << pack<LoadConfig>(c);
params.insert("loadConfigs", list);
emit LoadConfigChanged(params);
});
}
// Spot market manager
params.clear();
description = "Emitted whenever the spot market manager status changed.";
@ -494,39 +429,6 @@ JsonReply *NymeaEnergyJsonHandler::GetChargingSchedules(const QVariantMap &param
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
QVariantList list;
if (m_loadConfigStore) {
foreach (const LoadConfig &c, m_loadConfigStore->configs())
list << pack<LoadConfig>(c); // inclut les charges enabled==false (éditables par l'app)
}
returns.insert("loadConfigs", list);
return createReply(returns);
}
JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap &params)
{
if (!m_loadConfigStore) {
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig sans store configuré.";
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
}
LoadConfigs configs;
foreach (const QVariant &item, params.value("loadConfigs").toList())
configs.append(LoadConfig::fromMap(item.toMap()));
QString why;
if (!m_loadConfigStore->setConfigs(configs, &why)) {
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why;
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
}
// store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part.
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
}
void NymeaEnergyJsonHandler::sendSpotMarketConfigurationChangedNotification()
{
QVariantMap params;

View File

@ -33,13 +33,12 @@
class SmartChargingManager;
class SpotMarketManager;
class LoadConfigStore;
class NymeaEnergyJsonHandler : public JsonHandler
{
Q_OBJECT
public:
explicit NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore = nullptr, QObject *parent = nullptr);
explicit NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, QObject *parent = nullptr);
QString name() const override;
@ -65,9 +64,6 @@ public:
Q_INVOKABLE JsonReply *GetChargingSchedules(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetLoadConfig(const QVariantMap &params);
Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap &params);
signals:
void PhasePowerLimitChanged(const QVariantMap &params);
void AcquisitionToleranceChanged(const QVariantMap &params);
@ -79,12 +75,10 @@ signals:
void SpotMarketScoreEntriesChanged(const QVariantMap &params);
void ChargingSchedulesChanged(const QVariantMap &params);
void BatteryLevelConsiderationChanged(const QVariantMap &params);
void LoadConfigChanged(const QVariantMap &params);
private:
SpotMarketManager *m_spotMarketManager;
SmartChargingManager *m_smartChargingManager = nullptr;
LoadConfigStore *m_loadConfigStore = nullptr;
void sendSpotMarketConfigurationChangedNotification();

View File

@ -72,10 +72,6 @@ public:
ChargingSchedules chargingSchedules() const;
// [ETM] Mode dégradé L2 (watchdog fraîcheur compteur). Base = false ;
// overridé dans EnergyArbitrator. Exposé pour la notification JSON-RPC.
virtual bool degradedMode() const { return false; }
SpotMarketManager *spotMarketManager() const;
#ifdef ENERGY_SIMULATION
@ -97,30 +93,21 @@ signals:
void chargingUpdated();
#endif
// [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/).
// All changes below are visibility-only (private → protected / virtual added).
// Zero logic change. Revert by deleting this block and restoring private slots.
protected slots:
virtual void update(const QDateTime &currentDateTime); // [ETM] virtual added
void prepareInformation(const QDateTime &currentDateTime); // [ETM] private → protected
void planSpotMarketCharging(const QDateTime &currentDateTime); // [ETM] private → protected
void planSurplusCharging(const QDateTime &currentDateTime); // [ETM] private → protected
void adjustEvChargers(const QDateTime &currentDateTime); // [ETM] private → protected
void updateManualSoCsWithoutMeter(const QDateTime &currentDateTime); // [ETM] private → protected
void verifyOverloadProtection(const QDateTime &currentDateTime); // [ETM] private → protected
void verifyOverloadProtectionRecovery(const QDateTime &currentDateTime); // [ETM] private → protected
protected:
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime &currentDateTime); // [ETM] private → protected
// [ETM] Read-only state accessors — inline, no copies, no logic.
const QHash<ThingId, EvCharger *> &internalEvChargers() const { return m_evChargers; } // [ETM] new
const QHash<EvCharger *, ChargingActions> &internalChargingActions() const { return m_chargingActions; } // [ETM] new
RootMeter *internalRootMeter() const { return m_rootMeter; } // [ETM] new
// [ETM] END
private slots:
void update(const QDateTime &currentDateTime);
// Don't call these methods out of place. it's only meant to keep the otherwise long update() code tidy.
// Call update() if you want to trigger the smarties.
void prepareInformation(const QDateTime &currentDateTime);
void planSpotMarketCharging(const QDateTime &currentDateTime);
void planSurplusCharging(const QDateTime &currentDateTime);
void adjustEvChargers(const QDateTime &currentDateTime);
void updateManualSoCsWithMeter(EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry);
void updateManualSoCsWithoutMeter(const QDateTime &currentDateTime);
void verifyOverloadProtection(const QDateTime &currentDateTime);
void verifyOverloadProtectionRecovery(const QDateTime &currentDateTime);
void onThingAdded(Thing *thing);
void onThingRemoved(const ThingId &thingId);
void onActionExecuted(const Action &action, Thing::ThingError status);
@ -142,7 +129,7 @@ private:
QString chargerPhaseKey(EvCharger *evCharger) const;
EnergyManager *m_energyManager = nullptr;
ThingManager *m_thingManager = nullptr;
ThingManager *m_thingManager = nullptr;
SpotMarketManager *m_spotMarketManager = nullptr;
EnergyManagerConfiguration *m_configuration = nullptr;
@ -165,6 +152,8 @@ private:
RootMeter *m_rootMeter = nullptr;
QHash<ThingId, EvCharger *> m_evChargers;
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime &currentDateTime);
};
#endif // SMARTCHARGINGMANAGER_H

View File

@ -1,7 +1,2 @@
TEMPLATE = subdirs
SUBDIRS = energyplugin
# Tests compilés uniquement sur CONFIG+=build_tests (CI/dev local).
# Pas dans le build deb (dpkg-buildpackage ne passe pas build_tests).
build_tests {
SUBDIRS += tests
}
SUBDIRS = energyplugin tests

View File

@ -257,25 +257,6 @@ QNetworkReply *EnergyTestBase::setEnergyStorageStates(uint batteryLevel, int cur
return m_networkAccessManager->post(request, QByteArray());
}
QNetworkReply *EnergyTestBase::setPowerSwitchStates(bool power, double currentPower, quint16 port)
{
QUrl requestUrl;
requestUrl.setScheme("http");
requestUrl.setHost("127.0.0.1");
requestUrl.setPort(port);
requestUrl.setPath("/setstates");
QUrlQuery query;
query.addQueryItem("power", power ? "true" : "false");
query.addQueryItem("currentPower", QString::number(currentPower));
requestUrl.setQuery(query);
QNetworkRequest request(requestUrl);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
return m_networkAccessManager->post(request, QByteArray());
}
QNetworkReply *EnergyTestBase::getActionHistory(quint16 port)
{
QUrl requestUrl;
@ -457,49 +438,6 @@ QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpp
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
QUuid EnergyTestBase::addPowerSwitch(double nominalPower, quint16 port)
{
QVariantList thingParams;
QVariantMap portParam;
portParam.insert("paramTypeId", "{e3398429-45fd-4add-a789-4d11bfd9560f}");
portParam.insert("value", port);
QVariantMap nominalPowerParam;
nominalPowerParam.insert("paramTypeId", "{b850a4d1-af0f-477d-ac73-56071f371884}");
nominalPowerParam.insert("value", nominalPower);
thingParams.append(portParam);
thingParams.append(nominalPowerParam);
QVariantMap params;
params.insert("thingClassId", mockPowerSwitchThingClassId.toString());
// Nom unique par port : plusieurs relais (paliers ECS) peuvent coexister.
params.insert("name", QString("Power switch %1").arg(port));
params.insert("thingParams", thingParams);
QVariant response = injectAndWait("Integrations.AddThing", params);
verifyThingError(response);
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
QUuid EnergyTestBase::addEtmVariableLoad(quint16 port)
{
QVariantList thingParams;
QVariantMap portParam;
portParam.insert("paramTypeId", "{c8d52a1b-4f3e-4b02-a07c-3d9f2e6b8c51}"); // etmVariableLoad port
portParam.insert("value", port);
thingParams.append(portParam);
QVariantMap params;
params.insert("thingClassId", "{b7c41f0a-3e2d-4a91-9f6b-2c8e1d5a7b40}"); // etmVariableLoadThingClassId
params.insert("name", QString("etmVariableLoad %1").arg(port));
params.insert("thingParams", thingParams);
QVariant response = injectAndWait("Integrations.AddThing", params);
verifyThingError(response);
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
void EnergyTestBase::removeDevices()
{
QVariant configuredDevices = injectAndWait("Integrations.GetThings");

View File

@ -48,7 +48,6 @@ static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-46
static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399bc26fe7c3");
static QUuid mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6");
static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f");
static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747");
using namespace nymeaserver;
@ -82,7 +81,6 @@ public:
QNetworkReply *setChargerWithPhaseCountSwitchingStates(bool connected, bool power, bool pluggedIn, const QString &phases, int maxChargingCurrent, int maxChargingCurrentMaxValue, uint desiredPhaseCount, quint16 port = 26658);
QNetworkReply *setSimpleChargerStates(bool connected, bool power, bool pluggedIn, int phaseCount, int maxChargingCurrent, quint16 port = 26659);
QNetworkReply *setEnergyStorageStates(uint batteryLevel, int currentPower, quint16 port = 26660);
QNetworkReply *setPowerSwitchStates(bool power, double currentPower, quint16 port = 26661);
QNetworkReply *getActionHistory(quint16 port);
QNetworkReply *clearActionHistroy(quint16 port);
@ -93,9 +91,6 @@ public:
QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658);
QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659);
QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660);
QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661);
//! Mock thing implémentant l'interface etmvariableload (states currentPowerW / powerSetpoint).
QUuid addEtmVariableLoad(quint16 port = 27001);
void removeDevices();
QVariant removeDevice(const QUuid &thingId);
@ -109,7 +104,6 @@ protected:
quint16 m_mockChargerWithPhaseCountSwitchingDefaultPort = 26658;
quint16 m_mockSimpleChargerDefaultPort = 26659;
quint16 m_mockEnergyStorageDefaultPort = 26660;
quint16 m_mockPowerSwitchDefaultPort = 26661;
bool verifyActionExecuted(const QVariantList &actionHistory, const QString &actionName);
QVariant getLastValueFromExecutedAction(const QVariantList &actionHistory, const QString &actionName, const QString &paramName);

View File

@ -32,10 +32,6 @@
#include "../../../energyplugin/nymeaenergyjsonhandler.h"
#include "../../../energyplugin/energymanagerconfiguration.h"
#include "../../../energyplugin/spotmarket/spotmarketmanager.h"
#ifdef ETM_ARBITRATOR
#include "../../../energyplugin/etm/energyarbitrator.h"
#include "../../../energyplugin/etm/config/loadconfigstore.h"
#endif
#include <jsonrpc/jsonrpcserver.h>
#include <loggingcategories.h>
@ -85,22 +81,9 @@ void ExperiencePluginEnergyMock::init()
EnergyManagerConfiguration *configuration = new EnergyManagerConfiguration(this);
QNetworkAccessManager *networkManager = new QNetworkAccessManager(this);
m_spotMarketManager = new SpotMarketManager(networkManager, this);
LoadConfigStore *loadConfigStore = nullptr;
// [ETM] BEGIN — flip identique à energypluginnymea.cpp
#ifdef ETM_ARBITRATOR
qCDebug(dcEnergyExperience()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé (simulation).";
EnergyArbitrator *arbitrator = new EnergyArbitrator(m_energyManager, thingManager(), m_spotMarketManager, configuration, this);
// Store config charge pilotée câblé comme en prod (energypluginnymea.cpp) : permet le test
// d'injection RPC NymeaEnergy.Get/SetLoadConfig end-to-end (handler → store → arbitre).
loadConfigStore = new LoadConfigStore(this);
arbitrator->setLoadConfigStore(loadConfigStore);
m_smartChargingManager = arbitrator;
#else
m_smartChargingManager = new SmartChargingManager(m_energyManager, thingManager(), m_spotMarketManager, configuration, this);
#endif
// [ETM] END
m_nymeaEnergyJsonHandler = new NymeaEnergyJsonHandler(m_spotMarketManager, m_smartChargingManager, loadConfigStore, this);
m_nymeaEnergyJsonHandler = new NymeaEnergyJsonHandler(m_spotMarketManager, m_smartChargingManager, this);
jsonRpcServer()->registerExperienceHandler(m_nymeaEnergyJsonHandler, 0, 2);
}

View File

@ -33,20 +33,6 @@ using namespace nymeaserver;
#include "../../../energyplugin/smartchargingmanager.h"
#include "../../mocks/spotmarketprovider/spotmarketdataprovidermock.h"
#ifdef ETM_ARBITRATOR
#include "../../../energyplugin/etm/energyarbitrator.h"
#include "../../../energyplugin/etm/adapters/sgreadyadapter.h"
// [T3] ecsrelayadapter.h retiré : l'ECS est piloté en watts via EtmVariableLoadAdapter
// (interface etmvariableload, kind Setpoint). La combinatoire relais vit dans le thing.
#include "../../../energyplugin/etm/adapters/etmvariableloadadapter.h"
#include "../../../energyplugin/etm/adapters/relayrouter.h"
#include "../../../energyplugin/etm/types/loadconfig.h"
#include "../../../energyplugin/etm/config/loadconfigstore.h"
#endif
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHash>
#include <QtMath>
@ -55,765 +41,11 @@ using namespace nymeaserver;
#include <QDateTime>
#include <QSignalSpy>
#include <QProcessEnvironment>
#include <QCoreApplication>
#include <nymeacore.h>
#include "simulationtestpoint.h"
void Simulation::testEcsSurplusPV()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsSurplusPV nécessite ETM_ARBITRATOR.");
#else
// [T3] ECS piloté en WATTS via l'interface etmvariableload (contrat rév. 2 §3/§5) : l'arbitre
// arrondit le surplus au powerLevels déclaré (fixed) ou clampe à maxPowerW (dynamic), écrit
// powerSetpoint, et recrédite currentPowerW (début de cycle) pour l'anti-clignotement. La
// combinatoire matérielle (relais/triac) vit dans le thing — invisible côté moteur.
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// =================== FIXED : paliers déclarés [0, 1200, 2400] ===================
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterThingId = addMeter();
QVERIFY(!meterThingId.isNull());
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
QVERIFY(meterThing);
meterThing->setStateValue("connected", true);
QUuid ecsId = addEtmVariableLoad(27001);
QVERIFY(!ecsId.isNull());
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
QVERIFY(ecsThing);
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
thingManager, ecsId.toString(), "ECS variable",
QList<int>({0, 1200, 2400}), 2400, 1, LoadNeeds(), arbitrator);
arbitrator->registerEtmVariableLoadAdapter(ecs);
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); }; // <0 = export
auto setLoadW = [&](double w){ ecsThing->setStateValue("currentPowerW", w); }; // mesure réelle simulée
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// --- Cascade montante (la charge ne tire encore rien : currentPowerW = 0) ---
setLoadW(0);
setMeterW(-1000); cycle(t0); // budget 1000 < 1200 → 0 W
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
setMeterW(-1500); cycle(t0); // budget 1500 → palier 1200
QCOMPARE(qRound(ecs->currentSetpointW()), 1200);
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 1200); // round-trip interface
setMeterW(-2500); cycle(t0); // budget 2500 → palier 2400
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400);
// --- Anti-clignotement (recrédit) : la charge tire 2400, PV 2500 → export net 100.
// budget = 100 + 2400 (recrédit currentPowerW début de cycle) = 2500 → RESTE 2400.
// Sans recrédit : 100 → 0 → oscillation. C'est précisément le test du recrédit. ---
setLoadW(2400);
setMeterW(-100); cycle(t0);
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
// --- Délestage sur import : charge tire 2400, import 600 → budget = -600 + 2400 = 1800 → 1200. ---
setMeterW(600); cycle(t0);
QCOMPARE(qRound(ecs->currentSetpointW()), 1200);
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 1200);
// =================== DYNAMIC : modulation continue, maxPowerW 3000, sans powerLevels ===================
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb2 = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb2);
ThingManager *tm2 = NymeaCore::instance()->thingManager();
QUuid mId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(mId);
Thing *m2 = tm2->findConfiguredThing(mId);
QVERIFY(m2);
m2->setStateValue("connected", true);
QUuid rId = addEtmVariableLoad(27002);
Thing *rThing = tm2->findConfiguredThing(rId);
QVERIFY(rThing);
rThing->setStateValue("currentPowerW", 0);
EtmVariableLoadAdapter *router = new EtmVariableLoadAdapter(
tm2, rId.toString(), "Routeur PV", QList<int>(), 3000, 1, LoadNeeds(), arb2); // powerLevels vide → dynamic
arb2->registerEtmVariableLoadAdapter(router);
m2->setStateValue("currentPower", -2000); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
QCOMPARE(qRound(router->currentSetpointW()), 2000); // clamp(2000, 0, 3000)
m2->setStateValue("currentPower", -4000); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
QCOMPARE(qRound(router->currentSetpointW()), 3000); // plafonné à maxPowerW
m2->setStateValue("currentPower", 500); arb2->simulationCallUpdate(t0); QCoreApplication::processEvents();
QCOMPARE(qRound(router->currentSetpointW()), 0); // import → 0
#endif
}
void Simulation::testMeterSilentFallback()
{
#ifndef ETM_ARBITRATOR
QSKIP("testMeterSilentFallback nécessite ETM_ARBITRATOR.");
#else
// [T4] Repli L2 charge pilotée recâblé : compteur muet >90 s → setPowerSetpoint(0) force=true
// sur l'etmvariableload, planif suspendue (reste 0 sur N cycles), reprise au retour compteur.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterThingId = addMeter();
QVERIFY(!meterThingId.isNull());
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
QVERIFY(meterThing);
meterThing->setStateValue("connected", true);
QUuid ecsId = addEtmVariableLoad(27010);
QVERIFY(!ecsId.isNull());
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
QVERIFY(ecsThing);
EtmVariableLoadAdapter *ecs = new EtmVariableLoadAdapter(
thingManager, ecsId.toString(), "ECS repli",
QList<int>({0, 2400}), 2400, 1, LoadNeeds(), arbitrator);
arbitrator->registerEtmVariableLoadAdapter(ecs);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
auto setLoadW = [&](double w){ ecsThing->setStateValue("currentPowerW", w); };
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// ECS servi sur surplus, compteur frais à T0.
arbitrator->recordMeterUpdate(t0);
setLoadW(0);
setMeterW(-2500); cycle(t0); // budget 2500 → 2400
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
QVERIFY(!arbitrator->degradedMode());
// Compteur muet > 90 s → mode dégradé : setPowerSetpoint(0) force=true.
setLoadW(2400); // la charge tirait
arbitrator->evaluateMeterFreshness(t0.addSecs(91));
QCoreApplication::processEvents();
QVERIFY(arbitrator->degradedMode());
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 0);
// STABILITÉ : muet, plusieurs cycles, faux surplus piège → l'ECS RESTE à 0 (planif suspendue).
setMeterW(-3000);
foreach (int dt, QList<int>({92, 120, 200, 280})) {
cycle(t0.addSecs(dt));
QVERIFY2(arbitrator->degradedMode(), "degradedMode doit rester actif pendant le silence");
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
}
// REPRISE : compteur reparle → degradedMode retombe → recalcul (pas de restauration d'ancienne consigne).
arbitrator->recordMeterUpdate(t0.addSecs(300));
QVERIFY(!arbitrator->degradedMode());
setLoadW(0);
setMeterW(-1000); cycle(t0.addSecs(301)); // 1000 < 2400 → reste 0
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
setMeterW(-2500); cycle(t0.addSecs(302)); // surplus suffisant → 2400
QCOMPARE(qRound(ecs->currentSetpointW()), 2400);
// ===================== Cas RELAIS (rév. 3) — certification du trou T2 fermé =====================
// Compteur muet → le RelayRouter coupe TOUS ses relais, force=true (bypass minOn). Sans ce
// volet, le repli L2 du routeur n'est pas certifié : des relais ECS pourraient rester allumés.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb2 = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb2);
ThingManager *tm2 = NymeaCore::instance()->thingManager();
QUuid mId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(mId);
Thing *m2 = tm2->findConfiguredThing(mId);
QVERIFY(m2);
m2->setStateValue("connected", true);
QUuid rA = addPowerSwitch(1000, 26661);
QUuid rB = addPowerSwitch(1500, 26662);
Thing *relayA = tm2->findConfiguredThing(rA);
Thing *relayB = tm2->findConfiguredThing(rB);
QVERIFY(relayA && relayB);
// RelayRouter : paliers DÉRIVÉS [0, 1000, 1500, 2500] ; minOn=300 s (pour prouver le bypass force).
RelayRouter *router = new RelayRouter(
tm2, "ecs-relais-repli", "ECS relais (repli)",
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1500} }),
300, 0, 1, LoadNeeds(), arb2);
arb2->registerRelayRouter(router);
auto cycle2 = [&](const QDateTime &now){ arb2->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// Surplus 2500 → palier 2500 (A+B) : les DEUX relais ON.
arb2->recordMeterUpdate(t0);
m2->setStateValue("currentPower", -2500); cycle2(t0);
QCOMPARE(qRound(router->currentSetpointW()), 2500);
QCOMPARE(relayA->stateValue("power").toBool(), true);
QCOMPARE(relayB->stateValue("power").toBool(), true);
QVERIFY(!arb2->degradedMode());
// Compteur muet > 90 s → mode dégradé : TOUS les relais OFF, force=true (bypass minOn 300).
arb2->evaluateMeterFreshness(t0.addSecs(91));
QCoreApplication::processEvents();
QVERIFY(arb2->degradedMode());
QCOMPARE(qRound(router->currentSetpointW()), 0);
QCOMPARE(relayA->stateValue("power").toBool(), false); // trou T2 fermé : relais coupé
QCOMPARE(relayB->stateValue("power").toBool(), false);
// STABILITÉ : muet, faux surplus piège → les relais RESTENT OFF (planif suspendue).
m2->setStateValue("currentPower", -3000);
foreach (int dt, QList<int>({92, 200})) {
cycle2(t0.addSecs(dt));
QVERIFY2(arb2->degradedMode(), "degradedMode doit rester actif pendant le silence");
QCOMPARE(relayA->stateValue("power").toBool(), false);
QCOMPARE(relayB->stateValue("power").toBool(), false);
}
#endif
}
void Simulation::testLoadConfigPersistence()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigPersistence nécessite ETM_ARBITRATOR.");
#else
// Persistance §4 : SetConfigs → fichier écrit → relecture par un store neuf → round-trip
// mot pour mot (id/label/mode/powerLevels/maxPowerW/priority/enabled/needs).
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-persist.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
const QVariantMap entry{
{"id", "{11111111-2222-3333-4444-555555555555}"},
{"label", "Chauffe-eau"},
{"adapter", "etmvariableload"},
{"mode", "fixed"},
{"powerLevels", QVariantList() << 0 << 600 << 1200},
{"maxPowerW", 1200},
{"priority", 2},
{"enabled", true},
{"needs", QVariantMap{{"dailyDeadline", "06:00"}, {"minEnergyWhPerDay", 4000}}}
};
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(entry));
{
LoadConfigStore store1;
QString err;
QVERIFY2(store1.setConfigs(cfgs, &err), err.toUtf8());
}
QVERIFY(QFileInfo::exists(cfgPath));
LoadConfigStore store2; // relit le fichier
QCOMPARE(store2.configs().count(), 1);
const LoadConfig r = store2.configs().first();
QCOMPARE(r.label(), QString("Chauffe-eau"));
QCOMPARE(r.mode(), QString("fixed"));
QCOMPARE(r.powerLevelsInt(), QList<int>({0, 600, 1200}));
QCOMPARE(r.maxPowerW(), 1200);
QCOMPARE(r.priority(), 2);
QVERIFY(r.enabled());
QCOMPARE(r.needs().dailyDeadline(), QString("06:00"));
QCOMPARE(r.needs().minEnergyWhPerDay(), 4000);
// Validation : une config fixed sans 0 dans powerLevels est rejetée EN BLOC (rien persisté).
QVariantMap badEntry = entry;
badEntry["powerLevels"] = QVariantList() << 600 << 1200; // pas de 0
LoadConfigs bad;
bad.append(LoadConfig::fromMap(badEntry));
LoadConfigStore store3;
QString err2;
QVERIFY(!store3.setConfigs(bad, &err2));
QVERIFY(!err2.isEmpty());
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testLoadConfigBuildsAdapters()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigBuildsAdapters nécessite ETM_ARBITRATOR.");
#else
// SetConfigs → store.changed → l'arbitre (re)construit les adaptateurs etmvariableload.
// enabled==true servi par le surplus ; enabled==false JAMAIS piloté (exclu, contrat §9).
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arbitrator);
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = thingManager->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid id1 = addEtmVariableLoad(27020); // enabled
QUuid id2 = addEtmVariableLoad(27021); // disabled
Thing *t1 = thingManager->findConfiguredThing(id1);
Thing *t2 = thingManager->findConfiguredThing(id2);
QVERIFY(t1 && t2);
t1->setStateValue("currentPowerW", 0);
t2->setStateValue("currentPowerW", 0);
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-build.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
LoadConfigStore *store = new LoadConfigStore(arbitrator);
arbitrator->setLoadConfigStore(store);
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", id1.toString()}, {"label", "ECS actif"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
{"priority", 1}, {"enabled", true}}));
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", id2.toString()}, {"label", "ECS désactivé"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200}, {"maxPowerW", 1200},
{"priority", 2}, {"enabled", false}}));
QString err;
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild
// Surplus large (5000 W) : assez pour servir les DEUX si elles étaient actives.
meter->setStateValue("currentPower", -5000);
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QCOMPARE(qRound(t1->stateValue("powerSetpoint").toDouble()), 2400); // enabled → servi
QCOMPARE(qRound(t2->stateValue("powerSetpoint").toDouble()), 0); // disabled → jamais piloté
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testLoadConfigRpc()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigRpc nécessite ETM_ARBITRATOR.");
#else
// End-to-end JSON-RPC (la couche que l'app consomme) : GetLoadConfig vide → SetLoadConfig
// (fixed réelle + dynamic) → energyError NoError → GetLoadConfig round-trip → effet RÉEL sur
// le thing (RPC → store → changed → arbitre reconstruit → adaptateur → powerSetpoint) →
// rejet d'une config invalide.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rpc.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); // avant initTestCase → store sur ce chemin
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arbitrator);
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = thingManager->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid ecsId = addEtmVariableLoad(27030); // thing réel piloté par la config fixed
Thing *ecsThing = thingManager->findConfiguredThing(ecsId);
QVERIFY(ecsThing);
ecsThing->setStateValue("currentPowerW", 0);
// 1. GetLoadConfig initial → vide.
QVariant resp = injectAndWait("NymeaEnergy.GetLoadConfig");
QVERIFY(resp.toMap().value("params").toMap().value("loadConfigs").toList().isEmpty());
// 2. SetLoadConfig : les DEUX formes rév. 3 (mutuellement exclusives) doivent être acceptées
// par le MÊME schéma SET — sinon nymea rejette une forme avant le handler (bug objectRef
// strict de T4, doublé). Une etmvariableload fixed (thing réel) + une dynamic + une relay-router.
QVariantList loadConfigs;
loadConfigs << QVariantMap{
{"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 1200 << 2400}, {"maxPowerW", 2400},
{"priority", 2}, {"enabled", true},
{"needs", QVariantMap{{"dailyDeadline", "06:00"}, {"minEnergyWhPerDay", 4000}}}};
loadConfigs << QVariantMap{
{"id", "{b033b212-1adb-4df0-ba2b-8fa477de52a2}"}, {"label", "Routeur PV"},
{"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000},
{"priority", 1}, {"enabled", true}};
loadConfigs << QVariantMap{ // ← rév. 3 : relay-router (relays[])
{"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 3}, {"enabled", true},
{"relays", QVariantList()
<< QVariantMap{{"thingId", "{aaaaaaaa-1111-2222-3333-444444444444}"}, {"powerW", 1000}}
<< QVariantMap{{"thingId", "{bbbbbbbb-1111-2222-3333-444444444444}"}, {"powerW", 1500}}},
{"minOnS", 60}, {"minOffS", 60}};
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", loadConfigs}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError"));
// 3. GetLoadConfig → round-trip des 3 entrées (les 2 formes), champs préservés (pack/unpack).
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
const QVariantList got = resp.toMap().value("params").toMap().value("loadConfigs").toList();
QCOMPARE(got.size(), 3);
QVariantMap fixedGot, relayGot;
for (const QVariant &v : got) {
const QVariantMap m = v.toMap();
if (m.value("id").toString() == ecsId.toString()) fixedGot = m;
if (m.value("adapter").toString() == "relay-router") relayGot = m;
}
QVERIFY(!fixedGot.isEmpty());
QCOMPARE(fixedGot.value("mode").toString(), QString("fixed"));
QCOMPARE(fixedGot.value("label").toString(), QString("Chauffe-eau"));
QVariantList levels = fixedGot.value("powerLevels").toList();
QCOMPARE(levels.size(), 3);
QCOMPARE(levels.last().toInt(), 2400);
QCOMPARE(fixedGot.value("needs").toMap().value("dailyDeadline").toString(), QString("06:00"));
// Forme relay-router : relays[] + minOnS round-trip (jamais piloté ici — RelayRouter en étape 4).
QVERIFY(!relayGot.isEmpty());
QVariantList relays = relayGot.value("relays").toList();
QCOMPARE(relays.size(), 2);
QCOMPARE(relays.last().toMap().value("powerW").toInt(), 1500);
QCOMPARE(relayGot.value("minOnS").toInt(), 60);
// 4. Fichier persisté.
QVERIFY(QFileInfo::exists(cfgPath));
// 5. EFFET RÉEL : le Set RPC a reconstruit les adaptateurs → un cycle surplus pilote le thing.
// Cascade du waterfall unifié construit DEPUIS la config RPC : surplus 6000 → routeur dynamic
// (rang 1) prend 3000 (clamp maxPowerW), reliquat 3000 → ECS fixed (rang 2) prend le palier 2400.
meter->setStateValue("currentPower", -6000);
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400); // reliquat 3000 → palier 2400
// 6. Validation CONDITIONNELLE — rejet en bloc (energyError InvalidParameter) :
// (a) etmvariableload fixed sans 0 dans powerLevels ; (b) relay-router avec relays[] vide.
QVariantList badA;
badA << QVariantMap{
{"id", "{cccccccc-2222-3333-4444-555555555555}"}, {"label", "Bancal"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 1200 << 2400}, {"maxPowerW", 2400},
{"priority", 1}, {"enabled", true}};
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badA}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
QVariantList badB;
badB << QVariantMap{
{"id", "ecs-vide"}, {"label", "ECS sans relais"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
{"relays", QVariantList()}}; // relays[] vide → invalide
resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", badB}});
QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
// Aucun rejet n'a écrasé : GetLoadConfig retourne toujours les 3 valides.
resp = injectAndWait("NymeaEnergy.GetLoadConfig");
QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 3);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::testSgReadySurplus()
{
#ifndef ETM_ARBITRATOR
QSKIP("testSgReadySurplus nécessite ETM_ARBITRATOR.");
#else
// Encodage SG-Ready 2 bits (K1,K2) : 1=[K1] blocage · 2=[] normal · 3=[K2] reco · 4=[K1,K2] forcé.
// estimatedPowerW déclaré : P3=1500, P4=3000. Hystérésis état 4 : entrée P4×1,2=3600, sortie P4×1,0=3000.
const QHash<int, double> pacPower({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// ===================== Volets 1-3 : PAC seule =====================
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator");
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid k1 = addPowerSwitch(0, 26661);
QUuid k2 = addPowerSwitch(0, 26662);
Thing *relayK1 = tm->findConfiguredThing(k1);
Thing *relayK2 = tm->findConfiguredThing(k2);
QVERIFY(relayK1 && relayK2);
SgReadyAdapter *pac = new SgReadyAdapter(
tm, "pac-test", "PAC test",
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
{3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }),
pacPower, 300, 1, arbitrator);
arbitrator->registerSgReadyAdapter(pac);
auto setMeterW = [&](double signedW){ meter->setStateValue("currentPower", signedW); }; // <0 export
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// --- Volet 1 : montée d'états 2 → 3 → 4 (mapping sémantique) ---
setMeterW(-1000); cycle(t0); // budget 1000 < P3 → état 2 (normal)
QCOMPARE(pac->currentState(), 2);
setMeterW(-2000); cycle(t0); // budget 2000 ≥ P3 → état 3 (reco)
QCOMPARE(pac->currentState(), 3);
QCOMPARE(relayK2->stateValue("power").toBool(), true);
QCOMPARE(relayK1->stateValue("power").toBool(), false);
setMeterW(-2500); cycle(t0.addSecs(400)); // budget 2500+1500=4000 ≥ P4×1,2 → état 4 (hold écoulé)
QCOMPARE(pac->currentState(), 4);
QCOMPARE(relayK1->stateValue("power").toBool(), true);
QCOMPARE(relayK2->stateValue("power").toBool(), true);
// --- Volet 2 : hystérésis 3↔4 (budget oscille dans la zone morte [P4×1,0 ; P4×1,2)) ---
// hold écoulé à chaque cycle (lastSwitch=T0+400) → c'est la ZONE MORTE qui tient l'état 4, pas le verrou.
setMeterW(-300); cycle(t0.addSecs(800)); // budget 300+3000=3300 ∈ [3000,3600) → reste 4
QCOMPARE(pac->currentState(), 4);
setMeterW(-100); cycle(t0.addSecs(1200)); // budget 3100 → reste 4
QCOMPARE(pac->currentState(), 4);
setMeterW(-500); cycle(t0.addSecs(1600)); // budget 3500 → reste 4
QCOMPARE(pac->currentState(), 4);
// En-dessous de P4×1,0 → sort enfin de l'état 4 (vers 3).
setMeterW(200); cycle(t0.addSecs(2000)); // import 200 → budget -200+3000=2800 < 3000 → état 3
QCOMPARE(pac->currentState(), 3);
// --- Volet 3 : protection court-cycling (changement avant minStateHold → GELÉ) ---
// lastSwitch=T0+2000. À T0+2100 (elapsed 100 < hold 300) : surplus abondant mais GELÉ en 3.
setMeterW(-3000); cycle(t0.addSecs(2100));
QCOMPARE(pac->currentState(), 3); // gelé malgré budget ≥ P4×1,2 (protection compresseur)
// À T0+2400 (elapsed 400 > hold) : MÊME surplus → bascule en 4. Seul le temps simulé a changé.
setMeterW(-3000); cycle(t0.addSecs(2400));
QCOMPARE(pac->currentState(), 4);
// ===================== Volet 4 : budget PARTAGÉ ECS(etmvariableload)↔PAC =====================
// [T3] Surplus 3000 W ; ECS palier 2400 W (etmvariableload, kind Setpoint), PAC P3 = 1500.
// Selon l'ordre de priorité, l'un se sert et l'autre voit le RELIQUAT → waterfall unifié
// (un seul budget cascade à travers etmvariableload ET sg-ready, triés par priorité).
auto runSharedBudget = [&](int ecsPrio, int pacPrio, double &ecsSetpointOut, int &pacStateOut) {
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm2 = NymeaCore::instance()->thingManager();
QUuid mId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(mId);
Thing *m2 = tm2->findConfiguredThing(mId);
QVERIFY(m2);
m2->setStateValue("connected", true);
QUuid eId = addEtmVariableLoad(27003); // ECS etmvariableload
QUuid j1 = addPowerSwitch(0, 26661); // relais PAC K1
QUuid j2 = addPowerSwitch(0, 26662); // relais PAC K2
Thing *eThing = tm2->findConfiguredThing(eId);
QVERIFY(eThing);
eThing->setStateValue("currentPowerW", 0);
// ECS : 1 palier à 2400 W (fixed). La charge ne tire encore rien (currentPowerW=0).
EtmVariableLoadAdapter *ecsWf = new EtmVariableLoadAdapter(
tm2, eId.toString(), "ECS waterfall", QList<int>({0, 2400}), 2400, ecsPrio, LoadNeeds(), arb);
arb->registerEtmVariableLoadAdapter(ecsWf);
SgReadyAdapter *pacWf = new SgReadyAdapter(
tm2, "pac-wf", "PAC waterfall",
QHash<int, QList<QString>>({ {1, {j1.toString()}}, {2, {}},
{3, {j2.toString()}}, {4, {j1.toString(), j2.toString()}} }),
pacPower, 300, pacPrio, arb);
arb->registerSgReadyAdapter(pacWf);
m2->setStateValue("currentPower", -3000); // export 3000 W
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
ecsSetpointOut = ecsWf->currentSetpointW();
pacStateOut = pacWf->currentState();
};
double ecsSetpoint = -1;
int pacState = -1;
// ECS prioritaire (rang 1) : ECS se sert (2400) → reliquat 600 < P3 → PAC reste NORMAL (2).
runSharedBudget(/*ecsPrio*/ 1, /*pacPrio*/ 2, ecsSetpoint, pacState);
QCOMPARE(qRound(ecsSetpoint), 2400);
QCOMPARE(pacState, 2);
// Priorités INVERSÉES — PAC prioritaire (rang 1) : PAC se sert (état 3, 1500) → reliquat
// 1500 < 2400 → l'ECS reste à 0 W. L'ordre de service s'inverse (même budget unique).
runSharedBudget(/*ecsPrio*/ 2, /*pacPrio*/ 1, ecsSetpoint, pacState);
QCOMPARE(qRound(ecsSetpoint), 0);
QCOMPARE(pacState, 3);
#endif
}
void Simulation::testEcsRelayTopologies()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsRelayTopologies nécessite ETM_ARBITRATOR.");
#else
// [rév. 3] La combinatoire watts→relais vit DANS le RelayRouter (couche routeur, frontière
// déplacée). On teste directement applyAction(Setpoint W) : paliers DÉRIVÉS, arrondi à la
// combinaison ≤ setpoint, off-before-on non-cascadé, et DÉDUPLICATION des niveaux.
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
auto sp = [&](double w) {
LoadAction a;
a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
a.powerW = w; a.reason = QStringLiteral("test topo");
return a;
};
auto freshSetup = [&](ThingManager *&tm, QObject *&owner) {
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
tm = NymeaCore::instance()->thingManager();
owner = arb;
};
// ===================== Topologie 1 : 1 relais (dégénéré [0, 2000]) =====================
{
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
QUuid r = addPowerSwitch(2000, 26661);
Thing *relay = tm->findConfiguredThing(r);
QVERIFY(relay);
RelayRouter *ecs = new RelayRouter(tm, "ecs-1", "ECS 1 relais",
QList<LoadConfigRelay>({ {r.toString(), 2000} }), 0, 0, 1, LoadNeeds(), owner);
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 2000}));
ecs->applyAction(sp(2500), t0); // 2500 → palier 2000
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
QCOMPARE(relay->stateValue("power").toBool(), true);
ecs->applyAction(sp(1000), t0.addSecs(1)); // 1000 < 2000 → 0
QCOMPARE(qRound(ecs->currentSetpointW()), 0);
QCOMPARE(relay->stateValue("power").toBool(), false);
}
// ============= Topologie 2 : 3 relais 500/1000/2000 (NON-CASCADÉ, off-before-on) =============
{
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
QUuid r500 = addPowerSwitch(500, 26661);
QUuid r1000 = addPowerSwitch(1000, 26662);
QUuid r2000 = addPowerSwitch(2000, 26663);
Thing *t500 = tm->findConfiguredThing(r500);
Thing *t1000 = tm->findConfiguredThing(r1000);
Thing *t2000 = tm->findConfiguredThing(r2000);
QVERIFY(t500 && t1000 && t2000);
RelayRouter *ecs = new RelayRouter(tm, "ecs-3", "ECS 3 relais",
QList<LoadConfigRelay>({ {r500.toString(), 500}, {r1000.toString(), 1000}, {r2000.toString(), 2000} }),
0, 0, 1, LoadNeeds(), owner);
// 8 niveaux dérivés (2^3 combinaisons toutes distinctes).
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}));
// 1700 → palier 1500 = {r500, r1000} (r2000 OFF).
ecs->applyAction(sp(1700), t0);
QCOMPARE(qRound(ecs->currentSetpointW()), 1500);
QCOMPARE(t500->stateValue("power").toBool(), true);
QCOMPARE(t1000->stateValue("power").toBool(), true);
QCOMPARE(t2000->stateValue("power").toBool(), false);
// Transition NON-CASCADÉE 1500 → 2000 = {r2000} SEUL : commute 3 relais (off-before-on).
ecs->applyAction(sp(2000), t0.addSecs(1));
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
QCOMPARE(t500->stateValue("power").toBool(), false);
QCOMPARE(t1000->stateValue("power").toBool(), false);
QCOMPARE(t2000->stateValue("power").toBool(), true);
}
// ============= Topologie 3 : DÉDUPLICATION (deux relais identiques 1000 W) =============
{
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
QUuid rA = addPowerSwitch(1000, 26661);
QUuid rB = addPowerSwitch(1000, 26662);
Thing *tA = tm->findConfiguredThing(rA);
Thing *tB = tm->findConfiguredThing(rB);
QVERIFY(tA && tB);
RelayRouter *ecs = new RelayRouter(tm, "ecs-dup", "ECS dédup",
QList<LoadConfigRelay>({ {rA.toString(), 1000}, {rB.toString(), 1000} }), 0, 0, 1, LoadNeeds(), owner);
// 4 combinaisons MAIS deux donnent 1000 W → FUSIONNÉES : 3 niveaux, pas 4.
QCOMPARE(ecs->descriptor().declared.powerLevels, QList<int>({0, 1000, 2000}));
// 1200 → palier 1000 = UN SEUL relais (le premier de la combinaison dédupliquée).
ecs->applyAction(sp(1200), t0);
QCOMPARE(qRound(ecs->currentSetpointW()), 1000);
QCOMPARE(tA->stateValue("power").toBool(), true);
QCOMPARE(tB->stateValue("power").toBool(), false);
// 2200 → palier 2000 = les deux.
ecs->applyAction(sp(2200), t0.addSecs(1));
QCOMPARE(qRound(ecs->currentSetpointW()), 2000);
QCOMPARE(tA->stateValue("power").toBool(), true);
QCOMPARE(tB->stateValue("power").toBool(), true);
}
#endif
}
void Simulation::testLoadConfigRelayRouter()
{
#ifndef ETM_ARBITRATOR
QSKIP("testLoadConfigRelayRouter nécessite ETM_ARBITRATOR.");
#else
// Chaîne COMPLÈTE rév. 3 : SetConfigs(relays[]) → store.changed → rebuild construit un
// RelayRouter → cycle surplus → arrondi scheduler → routeur → commutation relais → currentPowerW.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arbitrator);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid rA = addPowerSwitch(1000, 26661);
QUuid rB = addPowerSwitch(1500, 26662);
Thing *relayA = tm->findConfiguredThing(rA);
Thing *relayB = tm->findConfiguredThing(rB);
QVERIFY(relayA && relayB);
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-relayrouter.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
LoadConfigStore *store = new LoadConfigStore(arbitrator);
arbitrator->setLoadConfigStore(store);
LoadConfigs cfgs;
cfgs.append(LoadConfig::fromMap(QVariantMap{
{"id", "ecs-relais"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true},
{"relays", QVariantList()
<< QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}
<< QVariantMap{{"thingId", rB.toString()}, {"powerW", 1500}}},
{"minOnS", 0}, {"minOffS", 0}}));
QString err;
QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); // persiste + changed → rebuild → RelayRouter
// Surplus 2500 → paliers dérivés [0,1000,1500,2500] → palier 2500 (rA+rB) → les deux ON.
meter->setStateValue("currentPower", -2500);
arbitrator->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QCOMPARE(relayA->stateValue("power").toBool(), true);
QCOMPARE(relayB->stateValue("power").toBool(), true);
// currentPowerW remonte (mock : relais ON → currentPower = nominal) → source du recrédit.
QCOMPARE(qRound(relayA->stateValue("currentPower").toDouble()), 1000);
QCOMPARE(qRound(relayB->stateValue("currentPower").toDouble()), 1500);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::run_data()
{
// Simulation infos

View File

@ -56,31 +56,6 @@ private slots:
void run_data();
void run();
// [T3] etmvariableload (ECS/routeur) : arrondi fixed (powerLevels) + clamp dynamic
// (maxPowerW), recrédit currentPowerW (anti-clignotement), délestage, round-trip powerSetpoint.
void testEcsSurplusPV();
// [T4] Watchdog L2 : compteur muet >90 s → etmvariableload setPowerSetpoint(0) force=true,
// planif suspendue (reste 0 sur N cycles), reprise au retour compteur.
void testMeterSilentFallback();
// [T4] Persistance LoadConfig (§4) : SetConfigs → fichier → relecture round-trip + validation.
void testLoadConfigPersistence();
// [T4] Construction des adaptateurs depuis la config : enabled servi, disabled exclu (§9).
void testLoadConfigBuildsAdapters();
// [T4] Injection RPC end-to-end : NymeaEnergy.Get/SetLoadConfig (couche consommée par l'app).
void testLoadConfigRpc();
// [rév.3] Chaîne complète : SetConfigs(relays[]) → rebuild → RelayRouter → cycle → commutation relais.
void testLoadConfigRelayRouter();
// SG-Ready (PAC) : montée d'états sur surplus, hystérésis 3↔4, protection court-cycling,
// + Volet 4 budget PARTAGÉ etmvariableload(ECS)↔PAC (waterfall unifié, inversion priorité).
void testSgReadySurplus();
// [rév.3] Combinatoire watts→relais DANS le RelayRouter : paliers dérivés, off-before-on,
// transition non-cascadée, et DÉDUPLICATION des niveaux (relais identiques fusionnés).
void testEcsRelayTopologies();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);

View File

@ -358,55 +358,6 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
thing->setStateValue("capacity", thing->paramValue(energyStorageThingCapacityParamTypeId));
return;
} else if (thing->thingClassId() == powerSwitchThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
// Permet au test d'imposer power / currentPower directement (ex. émuler une
// mesure dérivée, ou un thermostat coupé : power=true mais currentPower=0).
if (query.hasQueryItem("power"))
thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool());
if (query.hasQueryItem("currentPower"))
thing->setStateValue("currentPower", QVariant(query.queryItemValue("currentPower")).toDouble());
});
m_controllers.insert(thing, controller);
qCDebug(dcEnergyMocks()) << "Setting up power switch" << thing->name() << "finished successfully";
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == etmVariableLoadThingClassId) {
// Mock etmvariableload : un controller (nécessaire pour logActionExecuted dans
// executeAction) + injection éventuelle de currentPowerW. Le test impose plutôt
// currentPowerW directement via setStateValue (mesure simulée de la charge).
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
quint16 port = thing->paramValue(paramType.id()).toUInt();
if (!controller->listen(QHostAddress::Any, port)) {
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
delete controller;
info->finish(Thing::ThingErrorThingInUse);
return;
}
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
if (query.hasQueryItem("currentPowerW"))
thing->setStateValue("currentPowerW", QVariant(query.queryItemValue("currentPowerW")).toDouble());
});
m_controllers.insert(thing, controller);
qCDebug(dcEnergyMocks()) << "Setting up etmVariableLoad" << thing->name() << "finished successfully";
info->finish(Thing::ThingErrorNoError);
return;
}
}
@ -523,30 +474,6 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
}
}
if (thing->thingClassId() == powerSwitchThingClassId) {
if (actionType.name() == "power") {
bool power = action.paramValue(actionType.paramTypes().findByName("power").id()).toBool();
double nominal = thing->paramValue(thing->thingClass().paramTypes().findByName("nominalPower").id()).toDouble();
// Relais ON → consomme sa puissance nominale ; OFF → 0 W. Le test peut écraser
// currentPower via /setstates (ex. émuler une mesure dérivée).
thing->setStateValue("power", power);
thing->setStateValue("currentPower", power ? nominal : 0.0);
qCDebug(dcEnergyMocks()) << "Mock power switch" << thing->name() << "power" << power
<< "currentPower" << thing->stateValue("currentPower");
}
}
if (thing->thingClassId() == etmVariableLoadThingClassId) {
if (actionType.name() == "powerSetpoint") {
// L'EtmVariableLoadAdapter écrit la consigne ; le mock l'enregistre dans le state.
// currentPowerW (puissance réellement appliquée) reste piloté par le test —
// c'est le thing réel qui choisirait la combinaison matérielle (contrat §3).
double setpoint = action.paramValue(actionType.paramTypes().findByName("powerSetpoint").id()).toDouble();
thing->setStateValue("powerSetpoint", setpoint);
qCDebug(dcEnergyMocks()) << "Mock etmVariableLoad" << thing->name() << "powerSetpoint" << setpoint;
}
}
info->finish(Thing::ThingErrorNoError);
}

View File

@ -194,7 +194,7 @@
"name": "maxChargingCurrent",
"displayName": "Maximum charging current",
"displayNameAction": "Set maximum charging current",
"type": "double",
"type": "uint",
"defaultValue":6,
"minValue": 6,
"maxValue": 32,
@ -387,7 +387,7 @@
"name": "maxChargingCurrent",
"displayName": "Maximum charging current",
"displayNameAction": "Set maximum charging current",
"type": "double",
"type": "uint",
"defaultValue":6,
"minValue": 6,
"maxValue": 32,
@ -584,7 +584,7 @@
"name": "maxChargingCurrent",
"displayName": "Maximum charging current",
"displayNameAction": "Set maximum charging current",
"type": "double",
"type": "uint",
"defaultValue":6,
"minValue": 6,
"maxValue": 32,
@ -816,84 +816,6 @@
"defaultValue": false
}
]
},
{
"name": "powerSwitch",
"displayName": "Mocked Power Switch (relais ECS)",
"id": "841f8905-d1d7-4053-909f-01123b497747",
"createMethods": ["user"],
"interfaces": ["power"],
"paramTypes": [
{
"id": "e3398429-45fd-4add-a789-4d11bfd9560f",
"name": "port",
"displayName": "Port",
"type": "uint",
"defaultValue": 26661
},
{
"id": "b850a4d1-af0f-477d-ac73-56071f371884",
"name": "nominalPower",
"displayName": "Nominal power when ON",
"type": "double",
"unit": "Watt",
"defaultValue": 2000
}
],
"stateTypes": [
{
"id": "9fa6457c-6adb-4d4a-8d47-7bdb2db2c271",
"name": "power",
"displayName": "Power",
"displayNameAction": "Switch power",
"type": "bool",
"defaultValue": false,
"writable": true
},
{
"id": "0e7e6cd5-601b-4616-8bc9-191c10e9dac7",
"name": "currentPower",
"displayName": "Current power",
"type": "double",
"unit": "Watt",
"defaultValue": 0
}
]
},
{
"name": "etmVariableLoad",
"displayName": "Mocked etmvariableload (ECS/routeur à puissance pilotable)",
"id": "b7c41f0a-3e2d-4a91-9f6b-2c8e1d5a7b40",
"createMethods": ["user"],
"paramTypes": [
{
"id": "c8d52a1b-4f3e-4b02-a07c-3d9f2e6b8c51",
"name": "port",
"displayName": "Port",
"type": "uint",
"defaultValue": 27001
}
],
"stateTypes": [
{
"id": "d9e63b2c-5a4f-4c13-b18d-4e0a3f7c9d62",
"name": "currentPowerW",
"displayName": "Current power (W)",
"type": "double",
"unit": "Watt",
"defaultValue": 0
},
{
"id": "e0f74c3d-6b5a-4d24-9f2e-5f1b408ad073",
"name": "powerSetpoint",
"displayName": "Power setpoint",
"displayNameAction": "Set power setpoint",
"type": "double",
"unit": "Watt",
"defaultValue": 0,
"writable": true
}
]
}
]
}