Compare commits
29 Commits
main
...
feature/be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6670bed6cc | ||
|
|
dfdd9884d0 | ||
|
|
51760a7f61 | ||
|
|
e641f289db | ||
|
|
d8079e84e0 | ||
|
|
b06ac15714 | ||
|
|
093fa09b5e | ||
|
|
c6d7831df9 | ||
|
|
83d5ad9ed7 | ||
|
|
cbee13e455 | ||
|
|
54ba2296fa | ||
|
|
dde967da41 | ||
|
|
3a8eb5da86 | ||
|
|
5d67dc943d | ||
|
|
a471a23aeb | ||
|
|
f71e0405b4 | ||
|
|
312a2484ae | ||
|
|
0615e5f39d | ||
|
|
6298d5d42f | ||
|
|
7709057335 | ||
|
|
19951a1e3e | ||
|
|
5bb6da0e9f | ||
|
|
d8ebd65eba | ||
|
|
c3fedfe36b | ||
|
|
7d3fc6e5ea | ||
|
|
08039a3542 | ||
|
|
5f49e4ca3c | ||
|
|
9017a880ac | ||
|
|
4ae1939f93 |
20
.clangd
Normal file
20
.clangd
Normal file
@ -0,0 +1,20 @@
|
||||
# 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
3
.gitignore
vendored
@ -8,3 +8,6 @@ Makefile
|
||||
builddir/
|
||||
*_moc.cpp
|
||||
autogenerated/
|
||||
|
||||
# clangd — chemins absolus du poste local, ne pas versionner
|
||||
compile_commands.json
|
||||
|
||||
441
AGENTS.md
441
AGENTS.md
@ -1,21 +1,436 @@
|
||||
# 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 (ECS, PAC, batterie, relais).
|
||||
vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
|
||||
|
||||
- **Licence** : GPL-3.0 · **Miroir public** : OUI
|
||||
- **Agent** : energy-etm · **Branche** : feature/beta-rulebased · **Scope** : energyplugin/
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
## ÉTAT
|
||||
|
||||
## Références
|
||||
- `README.md` (architecture), `INTERFACE.md` (fait autorité sur l'API), `etm_powersync_energy.svg`.
|
||||
| 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` |
|
||||
|
||||
Carte globale et frontières : voir `../AGENTS.md`.
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## 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 | ~300–600 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`.
|
||||
|
||||
12
INTERFACE.md
12
INTERFACE.md
@ -453,10 +453,18 @@ S'abonner via `JSONRPC.SetNotificationStatus` avec le namespace `"NymeaEnergy"`.
|
||||
---
|
||||
|
||||
### `NymeaEnergy.ChargingSchedulesChanged`
|
||||
Émis à chaque recalcul du planning (cycle ~1 min).
|
||||
Émis à chaque recalcul du planning (cycle ~1 min), **et** à chaque transition du mode
|
||||
dégradé L2 (watchdog fraîcheur compteur).
|
||||
```json
|
||||
{ "chargingSchedules": [ ... ] }
|
||||
{
|
||||
"chargingSchedules": [ ... ],
|
||||
"o:degradedMode": false
|
||||
}
|
||||
```
|
||||
- `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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
209
docs/SAFETY.md
Normal file
209
docs/SAFETY.md
Normal file
@ -0,0 +1,209 @@
|
||||
# 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) |
|
||||
248
docs/TEST_TERRAIN.md
Normal file
248
docs/TEST_TERRAIN.md
Normal file
@ -0,0 +1,248 @@
|
||||
# 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** (vérifiés par `gpioget`).
|
||||
14 tests (T1–T14). Chaque test : **inject → log attendu → vérif GPIO → 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 nymea-dev | `__________` | **[À REMPLIR]** (réseau banc) |
|
||||
| Port JSON-RPC | `nymeas://<IP>:2222` (TCP+TLS) · `wss://<IP>: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` (param `port` par défaut) | connu |
|
||||
| ThingId relais R500 / R1000 / R2000 | `__________` | **[À LIRE]** `Integrations.GetThings` (plugin GPIO relais réel) |
|
||||
| ThingId relais SG-Ready K1 / K2 | `__________` | **[À LIRE]** idem |
|
||||
| gpiochip + offsets relais | `__________` | **[À LIRE]** `gpiodetect` puis `gpioinfo gpiochip0` |
|
||||
| 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=<IP> # [À REMPLIR]
|
||||
scp nymea-energy-plugin-nymea_*_arm64.deb root@$BOX:/tmp/
|
||||
ssh root@$BOX 'dpkg -i /tmp/nymea-energy-plugin-nymea_*_arm64.deb && systemctl restart nymead'
|
||||
ssh root@$BOX '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 root@$BOX 'mkdir -p /etc/systemd/system/nymead.service.d && \
|
||||
printf "[Service]\nEnvironment=QT_LOGGING_RULES=NymeaEnergy.debug=true\n" \
|
||||
> /etc/systemd/system/nymead.service.d/etm-logging.conf && \
|
||||
systemctl daemon-reload && 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) — ThingId réels [À REMPLIR] ---
|
||||
const QString R500 = "{__________}"; // relais GPIO 500 W
|
||||
const QString R1000 = "{__________}"; // relais GPIO 1000 W
|
||||
const QString R2000 = "{__________}"; // relais GPIO 2000 W
|
||||
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) — ThingId réels [À REMPLIR] ---
|
||||
const QString K1 = "{__________}";
|
||||
const QString K2 = "{__________}";
|
||||
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 ou box)
|
||||
```bash
|
||||
BOX=<IP>; MPORT=26655 # [À REMPLIR]
|
||||
# Injecter une puissance compteur (W). Négatif = export (surplus PV), positif = import.
|
||||
inject(){ curl -s "http://$BOX:$MPORT/setstates?connected=true¤tPowerPhaseA=$(($1/3))¤tPowerPhaseB=$(($1/3))¤tPowerPhaseC=$(($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; }
|
||||
# Lire un relais GPIO réel (0/1) : relay <gpiochip> <offset>
|
||||
relay(){ ssh root@$BOX "gpioget $1 $2"; } # ex. relay gpiochip0 17
|
||||
# Suivre les décisions
|
||||
logs(){ ssh root@$BOX "journalctl -u nymead -f | grep -E 'Arbitre|EcsRelay|SgReady'"; }
|
||||
```
|
||||
> 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 GPIO** : `relay <chip> <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 GPIO** : relais → **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 GPIO** : R500=**1**, R1000=**1**, 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 GPIO** : R500=**0**, R1000=**0**, 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 GPIO** : 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 GPIO** : R500=R1000=R2000=**0**
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
---
|
||||
|
||||
## §4 — SG-Ready (PAC, 2 bits K1/K2 ; P3=1500, P4=3000)
|
||||
|
||||
### T7 — Montée d'états 2 → 3 → 4
|
||||
- **inject** `-1000` → état **2** (K1=0,K2=0) ; **inject** `-2000` → état **3** (K2=1) ;
|
||||
attendre > minStateHold (300 s) puis **inject** `-2500` → état **4** (K1=1,K2=1).
|
||||
- **log** : `… recommandée (état 3 …)` puis `… forcée (état 4 …)`
|
||||
- **vérif GPIO** : état 3 → K1=0,K2=1 ; état 4 → K1=1,K2=1
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
### T8 — Atomicité (transitoire bénin)
|
||||
- **contexte** : transition 2→4 (00→11) commute K1 ET K2.
|
||||
- **vérif GPIO** : set **final** K1=**1**, K2=**1** (état 4). Le transitoire passe par K2 d'abord
|
||||
(01=reco), **jamais** 10=blocage — sub-ms, non observable à `gpioget` mais **garanti code**
|
||||
(`transientHarm`). Confirmer simplement le set final correct.
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
### T9 — Hystérésis (pas de bascule 3↔4)
|
||||
- **contexte** : PAC en état 4 ; faire osciller le surplus dans la zone morte.
|
||||
- **inject** `-300` (budget ≈ 3300) → reste **4** ; `-500` (≈3500) → reste **4** ; `-100` (≈3100) → reste **4**.
|
||||
- **vérif GPIO** : K1=K2=**1** stable (aucun clignotement 3↔4). Passer `inject 300` (budget < 3000) → sort en 3.
|
||||
- ✓ / ✗ : `____`
|
||||
|
||||
---
|
||||
|
||||
## §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 GPIO** : relais ECS → **0** ; PAC K1=0,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 GPIO** : 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 GPIO** : 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 GPIO** : 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é / repérage K1/K2** SG-Ready vérifié (1:0=blocage, 0:0=normal, 0:1=reco, 1:1=forcé).
|
||||
- [ ] Relais résistifs ECS : pas de charge inductive sur ces voies.
|
||||
- [ ] `gpioinfo` relevé et **mapping offset↔relais consigné** avant tout test.
|
||||
- [ ] L1 (failsafe bornes/relais) configuré si applicable ; L0 reste le filet ultime.
|
||||
- [ ] Un opérateur à la main sur le disjoncteur pendant T1–T6 (montées de puissance).
|
||||
@ -1,3 +1,12 @@
|
||||
# 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
|
||||
@ -37,6 +46,8 @@ HEADERS += \
|
||||
$$PWD/types/smartchargingstate.h \
|
||||
$$PWD/types/timeframe.h \
|
||||
|
||||
include($$PWD/etm/etm.pri)
|
||||
|
||||
SOURCES += \
|
||||
$$PWD/energymanagerconfiguration.cpp \
|
||||
$$PWD/energysettings.cpp \
|
||||
|
||||
@ -28,6 +28,12 @@
|
||||
#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"
|
||||
#endif
|
||||
// [ETM] END
|
||||
|
||||
#include "plugininfo.h"
|
||||
|
||||
EnergyPluginNymea::EnergyPluginNymea(QObject *parent) : EnergyPlugin(parent)
|
||||
@ -41,8 +47,14 @@ void EnergyPluginNymea::init()
|
||||
|
||||
EnergyManagerConfiguration *configuration = new EnergyManagerConfiguration(this);
|
||||
QNetworkAccessManager *networkManager = new QNetworkAccessManager(this);
|
||||
|
||||
SpotMarketManager *spotMarketManager = new SpotMarketManager(networkManager, this);
|
||||
|
||||
#ifdef ETM_ARBITRATOR
|
||||
qCDebug(dcNymeaEnergy()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé.";
|
||||
EnergyArbitrator *chargingManager = new EnergyArbitrator(energyManager(), thingManager(), spotMarketManager, configuration, this);
|
||||
#else
|
||||
SmartChargingManager *chargingManager = new SmartChargingManager(energyManager(), thingManager(), spotMarketManager, configuration, this);
|
||||
#endif
|
||||
|
||||
jsonRpcServer()->registerExperienceHandler(new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, this), 0, 8);
|
||||
}
|
||||
|
||||
217
energyplugin/etm/adapters/ecsrelayadapter.cpp
Normal file
217
energyplugin/etm/adapters/ecsrelayadapter.cpp
Normal file
@ -0,0 +1,217 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "ecsrelayadapter.h"
|
||||
#include "plugininfo.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <integrations/thingmanager.h>
|
||||
#include <integrations/thing.h>
|
||||
#include <types/action.h>
|
||||
#include <types/param.h>
|
||||
|
||||
EcsRelayAdapter::EcsRelayAdapter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QList<int> &stages,
|
||||
const QList<QList<QString>> &relayMapping,
|
||||
int minOnS,
|
||||
int minOffS,
|
||||
int priority,
|
||||
QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_thingManager(thingManager)
|
||||
, m_id(id)
|
||||
, m_label(label)
|
||||
, m_stages(stages)
|
||||
, m_relayMapping(relayMapping)
|
||||
, m_minOnS(minOnS)
|
||||
, m_minOffS(minOffS)
|
||||
, m_priority(priority)
|
||||
{
|
||||
Q_ASSERT(!m_stages.isEmpty() && m_stages.first() == 0);
|
||||
Q_ASSERT(m_relayMapping.size() == m_stages.size());
|
||||
}
|
||||
|
||||
LoadDescriptor EcsRelayAdapter::descriptor() const
|
||||
{
|
||||
LoadDescriptor d;
|
||||
d.id = m_id;
|
||||
d.label = m_label;
|
||||
d.adapter = QStringLiteral("relay-stages");
|
||||
d.priority = m_priority;
|
||||
|
||||
d.declared.stages = m_stages;
|
||||
d.limits.minOnS = m_minOnS;
|
||||
d.limits.minOffS = m_minOffS;
|
||||
|
||||
d.supportedKinds = { LoadAction::Stage };
|
||||
return d;
|
||||
}
|
||||
|
||||
LoadTelemetry EcsRelayAdapter::telemetry() const
|
||||
{
|
||||
LoadTelemetry t;
|
||||
t.available = true;
|
||||
t.lastActionAt = m_lastActionAt;
|
||||
|
||||
// Source de currentPowerW (consommée par le recrédit anti-clignotement du waterfall,
|
||||
// RuleBasedScheduler::buildEcsStageAction — correction B) :
|
||||
// - MESURÉE dès qu'au moins un relais du stage expose un state "currentPower" :
|
||||
// somme des mesures. Un thermostat interne coupé (relais ON mais 0 W réel) renvoie
|
||||
// alors 0 → recrédit 0, jamais de puissance fantôme.
|
||||
// - DÉCLARÉE (repli) : stages[stage] UNIQUEMENT si aucun relais actif ne mesure
|
||||
// (relais nus / mock sans powermetering). Approximation = palier à sa nominale.
|
||||
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 *t2 = m_thingManager->findConfiguredThing(ThingId(thingId));
|
||||
if (!t2)
|
||||
continue;
|
||||
if (!t2->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
|
||||
metered = true;
|
||||
power += t2->stateValue("currentPower").toDouble();
|
||||
}
|
||||
}
|
||||
// Repli déclaré : seulement si la mesure n'est pas disponible (pas si elle vaut 0).
|
||||
if (!metered && m_currentStage > 0 && m_currentStage < m_stages.size())
|
||||
power = m_stages.at(m_currentStage);
|
||||
|
||||
t.currentPowerW = power;
|
||||
return t;
|
||||
}
|
||||
|
||||
LoadContext EcsRelayAdapter::toLoadContext(const QDateTime &now) const
|
||||
{
|
||||
LoadContext ctx;
|
||||
ctx.id = m_id;
|
||||
ctx.adapter = QStringLiteral("relay-stages");
|
||||
ctx.label = m_label;
|
||||
ctx.priority = m_priority;
|
||||
ctx.declared = descriptor().declared;
|
||||
ctx.limits = descriptor().limits;
|
||||
|
||||
const LoadTelemetry tel = telemetry();
|
||||
ctx.telemetry.currentPowerW = tel.currentPowerW;
|
||||
ctx.telemetry.stage = m_currentStage;
|
||||
ctx.telemetry.lastSwitch = m_lastSwitch;
|
||||
// Fenêtre de verrou évaluée au temps de cycle : le scheduler y borne son choix
|
||||
// (plancher = puissance engagée non-coupable sous minOn ; plafond = redémarrage minOff).
|
||||
lockWindow(now, ctx.telemetry.minStage, ctx.telemetry.maxStage);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
LoadAction EcsRelayAdapter::applyAction(const LoadAction &action, const QDateTime &now)
|
||||
{
|
||||
if (action.kind != LoadAction::Stage)
|
||||
return action;
|
||||
|
||||
if (action.reason.isEmpty()) {
|
||||
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
|
||||
<< "— LoadAction sans reason rejetée.";
|
||||
return action;
|
||||
}
|
||||
|
||||
const int newStage = qBound(0, action.stage, m_stages.size() - 1);
|
||||
|
||||
if (newStage == m_currentStage)
|
||||
return action; // Aucun changement → idempotent
|
||||
|
||||
// Verrous anti-rebond évalués au temps de cycle (même fenêtre que le scheduler) —
|
||||
// bypassés si force == true (L2 watchdog).
|
||||
if (!action.force && lockActive(newStage, now)) {
|
||||
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
|
||||
<< "— verrou anti-rebond actif, stage" << newStage << "ignoré.";
|
||||
return action;
|
||||
}
|
||||
|
||||
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
|
||||
<< "→ stage" << newStage
|
||||
<< "(" << (m_currentStage < m_stages.size() ? m_stages.at(m_currentStage) : 0) << "W"
|
||||
<< "→" << m_stages.at(newStage) << "W)"
|
||||
<< "|" << action.reason;
|
||||
|
||||
applyRelayStage(newStage);
|
||||
|
||||
m_currentStage = newStage;
|
||||
m_lastSwitch = now; // estampille au temps de cycle (cohérent avec la fenêtre de verrou)
|
||||
m_lastActionAt = now;
|
||||
|
||||
LoadAction applied = action;
|
||||
applied.stage = newStage;
|
||||
applied.estimatedPowerW = m_stages.at(newStage);
|
||||
return applied;
|
||||
}
|
||||
|
||||
// ---- privé ---------------------------------------------------------------
|
||||
|
||||
void EcsRelayAdapter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
|
||||
{
|
||||
const int topStage = m_stages.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 sous le palier courant
|
||||
// (puissance engagée non-coupable, protection compresseur / anti court-cycling).
|
||||
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;
|
||||
}
|
||||
|
||||
bool EcsRelayAdapter::lockActive(int newStage, const QDateTime &now) const
|
||||
{
|
||||
// MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident.
|
||||
int minStage, maxStage;
|
||||
lockWindow(now, minStage, maxStage);
|
||||
return newStage < minStage || newStage > maxStage;
|
||||
}
|
||||
|
||||
void EcsRelayAdapter::applyRelayStage(int stage)
|
||||
{
|
||||
// Set de relais CIBLE du palier (delta complet : chaque relais connu est amené à son
|
||||
// état cible on/off, pas d'ajout incrémental). Gère les mappings NON-CASCADÉS où monter
|
||||
// d'un palier éteint des relais (ex. 3 résistances 500/1000/2000 W : 1500=[r500,r1000]
|
||||
// → 2000=[r2000] commute 3 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()) << "[EcsRelayAdapter]" << 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
|
||||
}
|
||||
};
|
||||
|
||||
// Intention (résistif : transitoire inoffensif, mais portée comme SG-Ready) : COUPER
|
||||
// d'abord les relais hors-cible, PUIS enclencher ceux de 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);
|
||||
}
|
||||
123
energyplugin/etm/adapters/ecsrelayadapter.h
Normal file
123
energyplugin/etm/adapters/ecsrelayadapter.h
Normal file
@ -0,0 +1,123 @@
|
||||
// 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 pour chauffe-eau ou tout relais N paliers (interface relay-stages).
|
||||
*
|
||||
* Pilote en production N Things powerswitch nymea : \c m_relayMapping[stage] contient
|
||||
* la liste des ThingIds à mettre ON pour ce palier (les autres sont mis OFF).
|
||||
*
|
||||
* Exemple — chauffe-eau 2400W, 2 résistances Waveshare :
|
||||
* stage 0 : {} → A=OFF, B=OFF
|
||||
* stage 1 : {"thingId-A"} → A=ON, B=OFF (1200 W)
|
||||
* stage 2 : {"thingId-A", "thingId-B"} → A=ON, B=ON (2400 W)
|
||||
*
|
||||
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
|
||||
* \invariant applyAction() applique les verrous anti-rebond \c minOnS / \c minOffS
|
||||
* SAUF si \c action.force == true (réservé L2 watchdog).
|
||||
* \invariant Le stage est écrêté à [0, stages().size()-1] avant envoi matériel.
|
||||
* \invariant Seul le kind Stage est traité ; les autres kinds retournent sans effet.
|
||||
*/
|
||||
class EcsRelayAdapter : public QObject, public ILoadAdapter
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/*!
|
||||
* \brief Constructeur.
|
||||
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds en Things.
|
||||
* \param id Identifiant logique de la charge (ThingId de l'objet ECS dans nymea,
|
||||
* ou identifiant arbitraire unique pour le mock).
|
||||
* \param label Nom lisible affiché dans les logs et l'app.
|
||||
* \param stages Puissances en W par palier, index 0 = off : [0, 1200, 2400].
|
||||
* \param relayMapping relayMapping[i] = liste de ThingIds powerswitch ON pour le palier i.
|
||||
* \param minOnS Durée minimale ON (s) — anti-rebond.
|
||||
* \param minOffS Durée minimale OFF (s) — anti-rebond.
|
||||
* \param priority Rang dans le waterfall (OPTIMIZER_PROTOCOL §5) : valeur plus BASSE
|
||||
* = servi en premier (rang 1 = premier servi).
|
||||
* \param parent Propriétaire Qt.
|
||||
*/
|
||||
explicit EcsRelayAdapter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
const QString &label,
|
||||
const QList<int> &stages,
|
||||
const QList<QList<QString>> &relayMapping,
|
||||
int minOnS,
|
||||
int minOffS,
|
||||
int priority,
|
||||
QObject *parent = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief Description statique de la charge.
|
||||
* \return LoadDescriptor avec adapter="relay-stages", stages, minOnS/minOffS, priority.
|
||||
*/
|
||||
LoadDescriptor descriptor() const override;
|
||||
|
||||
/*!
|
||||
* \brief Télémétrie runtime : puissance mesurée, stage courant, lastSwitch.
|
||||
* \return LoadTelemetry avec currentPowerW issu de la somme des Things actifs.
|
||||
*/
|
||||
LoadTelemetry telemetry() const override;
|
||||
|
||||
/*!
|
||||
* \brief Construit l'entrée loads[] §5 du SurplusContext.
|
||||
* \param now Temps de cycle (\c ctx.timestamp) — source unique pour minStage/maxStage.
|
||||
* \return LoadContext incluant declared, limits et télémétrie ECS (stage, currentPowerW,
|
||||
* lastSwitch, et la fenêtre de verrou \c minStage/maxStage évaluée à \p now).
|
||||
*/
|
||||
LoadContext toLoadContext(const QDateTime &now) const override;
|
||||
|
||||
/*!
|
||||
* \brief Applique un changement de palier sur les relais.
|
||||
*
|
||||
* \param action LoadAction de kind Stage. Autres kinds : retour sans effet.
|
||||
* \param now Temps de cycle (\c ctx.timestamp) — MÊME source que toLoadContext(),
|
||||
* utilisée pour l'évaluation des verrous et l'estampille \c m_lastSwitch.
|
||||
* \return L'action après écrêtage (stage borné à [0, stages.size()-1]).
|
||||
*
|
||||
* \invariant Si \c action.reason est vide → retour sans effet (log warning).
|
||||
* \invariant Si verrous anti-rebond actifs ET \c action.force == false → retour sans effet (log).
|
||||
* \invariant Si \c action.force == true → bypass verrous (L2 watchdog uniquement).
|
||||
* \invariant Toute modification de stage met à jour \c m_lastSwitch (= \p now).
|
||||
*/
|
||||
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
||||
|
||||
/*! \brief Stage courant (0 = off). */
|
||||
int currentStage() const { return m_currentStage; }
|
||||
|
||||
private:
|
||||
/*!
|
||||
* \brief Fenêtre de paliers autorisée à l'instant \p now par les verrous minOn/minOff.
|
||||
* \param now Temps de cycle.
|
||||
* \param[out] minStage Plancher : palier courant si minOn non écoulé (puissance engagée
|
||||
* non-coupable), sinon 0.
|
||||
* \param[out] maxStage Plafond : 0 si à l'arrêt et minOff non écoulé (redémarrage interdit),
|
||||
* sinon le palier le plus haut.
|
||||
*/
|
||||
void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const;
|
||||
|
||||
bool lockActive(int newStage, const QDateTime &now) const;
|
||||
void applyRelayStage(int stage);
|
||||
|
||||
ThingManager *m_thingManager;
|
||||
QString m_id;
|
||||
QString m_label;
|
||||
QList<int> m_stages; //!< Puissances W par palier, [0]=off.
|
||||
QList<QList<QString>> m_relayMapping; //!< ThingIds ON par palier.
|
||||
int m_minOnS;
|
||||
int m_minOffS;
|
||||
int m_priority;
|
||||
|
||||
int m_currentStage = 0;
|
||||
QDateTime m_lastSwitch; //!< Dernier changement de palier (null = jamais).
|
||||
QDateTime m_lastActionAt;
|
||||
};
|
||||
94
energyplugin/etm/adapters/evadapter.cpp
Normal file
94
energyplugin/etm/adapters/evadapter.cpp
Normal file
@ -0,0 +1,94 @@
|
||||
// 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;
|
||||
}
|
||||
81
energyplugin/etm/adapters/evadapter.h
Normal file
81
energyplugin/etm/adapters/evadapter.h
Normal file
@ -0,0 +1,81 @@
|
||||
// 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;
|
||||
};
|
||||
75
energyplugin/etm/adapters/iloadadapter.h
Normal file
75
energyplugin/etm/adapters/iloadadapter.h
Normal file
@ -0,0 +1,75 @@
|
||||
// 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 été 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;
|
||||
};
|
||||
235
energyplugin/etm/adapters/sgreadyadapter.cpp
Normal file
235
energyplugin/etm/adapters/sgreadyadapter.cpp
Normal file
@ -0,0 +1,235 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
qCDebug(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));
|
||||
}
|
||||
125
energyplugin/etm/adapters/sgreadyadapter.h
Normal file
125
energyplugin/etm/adapters/sgreadyadapter.h
Normal file
@ -0,0 +1,125 @@
|
||||
// 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. 2→4 : 00→11). 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;
|
||||
};
|
||||
305
energyplugin/etm/energyarbitrator.cpp
Normal file
305
energyplugin/etm/energyarbitrator.cpp
Normal file
@ -0,0 +1,305 @@
|
||||
// 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/ecsrelayadapter.h"
|
||||
#include "adapters/sgreadyadapter.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))
|
||||
{
|
||||
// --- 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::registerEcsAdapter(EcsRelayAdapter *adapter)
|
||||
{
|
||||
const QString id = adapter->descriptor().id;
|
||||
if (m_ecsAdapters.contains(id)) {
|
||||
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter déjà enregistré:" << id;
|
||||
return;
|
||||
}
|
||||
adapter->setParent(this);
|
||||
m_ecsAdapters[id] = adapter;
|
||||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter enregistré:" << adapter->descriptor().label;
|
||||
}
|
||||
|
||||
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::update(const QDateTime ¤tDateTime)
|
||||
{
|
||||
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); // ECS (kind==Stage) → m_ecsAdapters
|
||||
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[] : ECS relay adapters ---
|
||||
for (auto it = m_ecsAdapters.constBegin(); it != m_ecsAdapters.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::Stage) {
|
||||
EcsRelayAdapter *adapter = m_ecsAdapters.value(action.loadId);
|
||||
if (adapter)
|
||||
adapter->applyAction(action, now);
|
||||
else
|
||||
qCWarning(dcNymeaEnergy()) << "[Arbitre] action Stage sans adaptateur ECS:" << action.loadId;
|
||||
|
||||
} else 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;
|
||||
}
|
||||
// EV (Setpoint) : dispatché par adjustEvChargers() amont jusqu'à 3g.
|
||||
}
|
||||
}
|
||||
|
||||
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)");
|
||||
|
||||
// ECS : tous paliers coupés (stage 0), force=true → bypass des verrous anti-rebond.
|
||||
for (EcsRelayAdapter *adapter : m_ecsAdapters) {
|
||||
LoadAction la;
|
||||
la.loadId = adapter->descriptor().id;
|
||||
la.kind = LoadAction::Stage;
|
||||
la.stage = 0;
|
||||
la.force = true;
|
||||
la.reason = reason;
|
||||
adapter->applyAction(la, now); // force=true → bypass verrous ; now = temps de cycle
|
||||
}
|
||||
|
||||
// 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).
|
||||
}
|
||||
212
energyplugin/etm/energyarbitrator.h
Normal file
212
energyplugin/etm/energyarbitrator.h
Normal file
@ -0,0 +1,212 @@
|
||||
// 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 EcsRelayAdapter;
|
||||
class SgReadyAdapter;
|
||||
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 *> ®isteredEvChargers() const;
|
||||
|
||||
/*!
|
||||
* \brief Root meter courant.
|
||||
* \return Pointeur ou nullptr si aucun compteur principal n'est enregistré.
|
||||
*/
|
||||
RootMeter *registeredRootMeter() const;
|
||||
|
||||
/*!
|
||||
* \brief Enregistre un EcsRelayAdapter pour inclusion dans le contexte et le dispatch.
|
||||
*
|
||||
* Appelé par le test (setup) ou la configuration de production.
|
||||
* L'adaptateur est adopté comme enfant Qt de l'arbitre.
|
||||
* \param adapter Adaptateur à enregistrer. Son \c descriptor().id doit être unique.
|
||||
*/
|
||||
void registerEcsAdapter(EcsRelayAdapter *adapter);
|
||||
|
||||
/*!
|
||||
* \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 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() (ECS Stage + SG-Ready State) + adjustEvChargers() (EV) → dispatch
|
||||
*
|
||||
* \param currentDateTime Instant courant (timer ou simulation).
|
||||
*/
|
||||
void update(const QDateTime ¤tDateTime) override;
|
||||
|
||||
private:
|
||||
/*!
|
||||
* \brief Construit le SurplusContext §5 : meter brut + loads EV + ECS + 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 aux fenêtres de verrou (\c minStage/maxStage, \c minState/maxState) calculées
|
||||
* par chaque adaptateur — cohérence décision (scheduler) / exécution (applyAction).
|
||||
*/
|
||||
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 ECS via interface 'ecsrelay' ThingManager — déféré 3g config.
|
||||
* En beta, les EcsRelayAdapters sont enregistrés via registerEcsAdapter().
|
||||
*/
|
||||
void syncAdapters();
|
||||
|
||||
/*!
|
||||
* \brief Applique les actions d'un Slot aux LoadAdapters non-EV (ECS + SG-Ready).
|
||||
*
|
||||
* Itère \c slot.actions et dispatche selon le kind : \c Stage → \c m_ecsAdapters
|
||||
* (EcsRelayAdapter) ; \c State → \c m_sgReadyAdapters (SgReadyAdapter). Les actions EV
|
||||
* (\c Setpoint) restent dispatchées par \c adjustEvChargers() amont jusqu'à 3g.
|
||||
* L'adaptateur écrête/verrouille lui-même (anti-rebond) 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 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 ré-é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, EcsRelayAdapter *> m_ecsAdapters; //!< loadId → EcsRelayAdapter*.
|
||||
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
|
||||
|
||||
// --- 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.
|
||||
};
|
||||
19
energyplugin/etm/etm.pri
Normal file
19
energyplugin/etm/etm.pri
Normal file
@ -0,0 +1,19 @@
|
||||
HEADERS += \
|
||||
$$PWD/types/loadaction.h \
|
||||
$$PWD/types/loaddescriptor.h \
|
||||
$$PWD/types/surpluscontext.h \
|
||||
$$PWD/types/plan.h \
|
||||
$$PWD/adapters/iloadadapter.h \
|
||||
$$PWD/scheduler/ischeduler.h \
|
||||
$$PWD/adapters/evadapter.h \
|
||||
$$PWD/adapters/ecsrelayadapter.h \
|
||||
$$PWD/adapters/sgreadyadapter.h \
|
||||
$$PWD/scheduler/rulebasedscheduler.h \
|
||||
$$PWD/energyarbitrator.h \
|
||||
|
||||
SOURCES += \
|
||||
$$PWD/adapters/evadapter.cpp \
|
||||
$$PWD/adapters/ecsrelayadapter.cpp \
|
||||
$$PWD/adapters/sgreadyadapter.cpp \
|
||||
$$PWD/scheduler/rulebasedscheduler.cpp \
|
||||
$$PWD/energyarbitrator.cpp \
|
||||
31
energyplugin/etm/scheduler/ischeduler.h
Normal file
31
energyplugin/etm/scheduler/ischeduler.h
Normal file
@ -0,0 +1,31 @@
|
||||
// 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;
|
||||
};
|
||||
288
energyplugin/etm/scheduler/rulebasedscheduler.cpp
Normal file
288
energyplugin/etm/scheduler/rulebasedscheduler.cpp
Normal file
@ -0,0 +1,288 @@
|
||||
// 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 (ECS paliers + SG-Ready) 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é.
|
||||
QList<LoadContext> nonEvLoads;
|
||||
for (const LoadContext &lc : ctx.loads) {
|
||||
if (lc.adapter == QStringLiteral("relay-stages") || 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(buildEcsStageAction(lc, remainingSurplusW));
|
||||
}
|
||||
|
||||
// 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::buildEcsStageAction(const LoadContext &lc,
|
||||
double &remainingSurplusW) const
|
||||
{
|
||||
// Correction B (anti-clignotement) : on recrédite la conso actuelle de l'ECS au budget.
|
||||
// Sans ce recrédit, allumer un palier ferait chuter l'export mesuré → palier 0 → oscillation.
|
||||
const double budgetChargeW = remainingSurplusW + lc.telemetry.currentPowerW;
|
||||
|
||||
// Palier déclaré le plus haut qui tient dans le budget. stages[0] == 0 par construction
|
||||
// (Q_ASSERT EcsRelayAdapter).
|
||||
const QList<int> &stages = lc.declared.stages;
|
||||
const int topStage = stages.size() - 1;
|
||||
int budgetStage = 0;
|
||||
for (int i = 0; i < stages.size(); ++i) {
|
||||
if (stages.at(i) <= budgetChargeW)
|
||||
budgetStage = i;
|
||||
}
|
||||
|
||||
// Verrou (minOn/minOff) : le palier choisi est borné par la fenêtre déclarée par
|
||||
// l'adaptateur (calculée au MÊME ctx.timestamp). Une charge verrouillée ON garde son
|
||||
// palier (puissance engagée non-coupable) ; à l'arrêt sous minOff elle ne redémarre pas.
|
||||
const int lo = qBound(0, lc.telemetry.minStage, topStage);
|
||||
const int hi = qBound(lo, lc.telemetry.maxStage, topStage);
|
||||
const int bestStage = qBound(lo, budgetStage, hi);
|
||||
|
||||
LoadAction la;
|
||||
la.loadId = lc.id;
|
||||
la.kind = LoadAction::Stage;
|
||||
la.funding = LoadAction::Surplus;
|
||||
la.stage = bestStage;
|
||||
la.estimatedPowerW = stages.at(bestStage);
|
||||
|
||||
if (bestStage > budgetStage)
|
||||
// Maintenu au-dessus du budget : protection compresseur (minOn non écoulé).
|
||||
la.reason = QStringLiteral("Verrou minOn — %1 maintenu palier %2 (%3 W, surplus %4 W)")
|
||||
.arg(lc.label).arg(bestStage).arg(stages.at(bestStage)).arg(qRound(budgetChargeW));
|
||||
else if (bestStage < budgetStage)
|
||||
// Empêché de monter/redémarrer par minOff malgré le surplus.
|
||||
la.reason = QStringLiteral("Verrou minOff — %1 maintenu palier %2 (redémarrage trop tôt)")
|
||||
.arg(lc.label).arg(bestStage);
|
||||
else if (bestStage > 0)
|
||||
la.reason = QStringLiteral("Surplus PV %1 W — %2 palier %3 (%4 W)")
|
||||
.arg(qRound(budgetChargeW)).arg(lc.label)
|
||||
.arg(bestStage).arg(stages.at(bestStage));
|
||||
else
|
||||
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 éteint")
|
||||
.arg(qRound(budgetChargeW)).arg(lc.label);
|
||||
|
||||
// Budget restant pour les charges ECS suivantes (rang supérieur / priorité plus basse).
|
||||
remainingSurplusW = budgetChargeW - la.estimatedPowerW;
|
||||
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;
|
||||
}
|
||||
117
energyplugin/etm/scheduler/rulebasedscheduler.h
Normal file
117
energyplugin/etm/scheduler/rulebasedscheduler.h
Normal file
@ -0,0 +1,117 @@
|
||||
// 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 ECS / SG-Ready.
|
||||
*
|
||||
* \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 (3c/3e)** : un budget de surplus UNIQUE — net SIGNÉ
|
||||
* \c (exportW − importW) − evReservedW — cascade par **priorité ASC** (rang, 1 = premier
|
||||
* servi) à travers les charges \c relay-stages (ECS, \c buildEcsStageAction) ET
|
||||
* \c sg-ready (PAC, \c buildSgReadyStateAction). Anti-clignotement par **recrédit** de la
|
||||
* conso allouée ; **clamp lock-aware** \c minStage/maxStage et \c minState/maxState (verrous
|
||||
* minOn/minOff/minStateHold, protection compresseur) — la fenêtre est calculée au MÊME
|
||||
* \c ctx.timestamp que l'exécution (cf. \c ILoadAdapter). Ces LoadAction sont RÉELLEMENT
|
||||
* dispatchées par \c EnergyArbitrator::applyActionsToAdapters().
|
||||
*
|
||||
* À partir de **3g** : l'EV rejoindra 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 les charges \c relay-stages / \c sg-ready
|
||||
* triées par priorité — LoadAction(Stage/State) réellement dispatchées.
|
||||
*
|
||||
* \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 "stage" ECS par cascade de surplus (waterfall §6).
|
||||
*
|
||||
* Retient le palier déclaré le plus haut dont la puissance tient dans le budget courant.
|
||||
* Correction B (anti-clignotement) : recrédite d'abord \c lc.telemetry.currentPowerW au
|
||||
* budget (la conso actuelle de l'ECS est déjà soustraite de l'export mesuré), puis
|
||||
* décrémente \c remainingSurplusW de la puissance du palier retenu.
|
||||
*
|
||||
* \param lc Charge ECS (adapter == "relay-stages") du SurplusContext.
|
||||
* \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la
|
||||
* charge ECS suivante (priorité inférieure / rang supérieur).
|
||||
* \return LoadAction kind=Stage, funding=Surplus, \c reason français non vide.
|
||||
*/
|
||||
LoadAction buildEcsStageAction(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;
|
||||
};
|
||||
72
energyplugin/etm/types/loadaction.h
Normal file
72
energyplugin/etm/types/loadaction.h
Normal file
@ -0,0 +1,72 @@
|
||||
// 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 continue, palier discret, état ou contrainte. */
|
||||
enum Kind { Setpoint, Stage, 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;
|
||||
|
||||
// --- Stage relay-stages (ECS) ---
|
||||
int stage = 0; //!< Index de palier (0 = off, 1 = 1er palier, ...).
|
||||
|
||||
// --- 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;
|
||||
};
|
||||
86
energyplugin/etm/types/loaddescriptor.h
Normal file
86
energyplugin/etm/types/loaddescriptor.h
Normal file
@ -0,0 +1,86 @@
|
||||
// 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).
|
||||
|
||||
// --- relay-stages (ECS) ---
|
||||
//! Puissances en W par palier : [0, 1200, 2400] — index 0 = off.
|
||||
QList<int> stages;
|
||||
|
||||
// --- 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"|"relay-stages"|"sg-ready"|"battery".
|
||||
QString adapter;
|
||||
int priority = 0;
|
||||
LoadDeclared declared;
|
||||
LoadLimits limits;
|
||||
LoadNeeds needs;
|
||||
QList<LoadAction::Kind> supportedKinds; //!< Kinds acceptés par applyAction().
|
||||
};
|
||||
54
energyplugin/etm/types/plan.h
Normal file
54
energyplugin/etm/types/plan.h
Normal file
@ -0,0 +1,54 @@
|
||||
// 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(); }
|
||||
};
|
||||
120
energyplugin/etm/types/surpluscontext.h
Normal file
120
energyplugin/etm/types/surpluscontext.h
Normal file
@ -0,0 +1,120 @@
|
||||
// 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).
|
||||
// --- relay-stages ---
|
||||
int stage = 0;
|
||||
//! Fenêtre de paliers autorisée MAINTENANT par les verrous (calculée au temps de cycle).
|
||||
//! Le scheduler y borne son choix : minStage = plancher (verrou minOn, puissance
|
||||
//! engagée non-coupable) ; maxStage = plafond (verrou minOff, redémarrage interdit).
|
||||
int minStage = 0;
|
||||
int maxStage = 0;
|
||||
// --- 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 0–1 ; < 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"|"relay-stages"|"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
|
||||
};
|
||||
@ -197,6 +197,9 @@ 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
|
||||
@ -223,6 +226,7 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
schedules << pack<ChargingSchedule>(schedule);
|
||||
}
|
||||
params.insert("chargingSchedules", schedules);
|
||||
params.insert("degradedMode", m_smartChargingManager->degradedMode()); // [ETM] L2
|
||||
emit ChargingSchedulesChanged(params);
|
||||
});
|
||||
|
||||
|
||||
@ -72,6 +72,10 @@ 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
|
||||
@ -93,21 +97,30 @@ 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 ¤tDateTime); // [ETM] virtual added
|
||||
void prepareInformation(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void planSpotMarketCharging(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void planSurplusCharging(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void adjustEvChargers(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void updateManualSoCsWithoutMeter(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void verifyOverloadProtection(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
|
||||
protected:
|
||||
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [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 ¤tDateTime);
|
||||
|
||||
// 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 ¤tDateTime);
|
||||
void planSpotMarketCharging(const QDateTime ¤tDateTime);
|
||||
void planSurplusCharging(const QDateTime ¤tDateTime);
|
||||
void adjustEvChargers(const QDateTime ¤tDateTime);
|
||||
void updateManualSoCsWithMeter(EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry);
|
||||
void updateManualSoCsWithoutMeter(const QDateTime ¤tDateTime);
|
||||
|
||||
void verifyOverloadProtection(const QDateTime ¤tDateTime);
|
||||
void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime);
|
||||
|
||||
void onThingAdded(Thing *thing);
|
||||
void onThingRemoved(const ThingId &thingId);
|
||||
void onActionExecuted(const Action &action, Thing::ThingError status);
|
||||
@ -129,7 +142,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;
|
||||
|
||||
@ -152,8 +165,6 @@ private:
|
||||
RootMeter *m_rootMeter = nullptr;
|
||||
QHash<ThingId, EvCharger *> m_evChargers;
|
||||
|
||||
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime);
|
||||
|
||||
};
|
||||
|
||||
#endif // SMARTCHARGINGMANAGER_H
|
||||
|
||||
@ -257,6 +257,25 @@ 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;
|
||||
@ -438,6 +457,31 @@ 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();
|
||||
}
|
||||
|
||||
void EnergyTestBase::removeDevices()
|
||||
{
|
||||
QVariant configuredDevices = injectAndWait("Integrations.GetThings");
|
||||
|
||||
@ -48,6 +48,7 @@ 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;
|
||||
|
||||
@ -81,6 +82,7 @@ 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);
|
||||
@ -91,6 +93,7 @@ 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);
|
||||
|
||||
void removeDevices();
|
||||
QVariant removeDevice(const QUuid &thingId);
|
||||
@ -104,6 +107,7 @@ 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 ¶mName);
|
||||
|
||||
@ -32,6 +32,9 @@
|
||||
#include "../../../energyplugin/nymeaenergyjsonhandler.h"
|
||||
#include "../../../energyplugin/energymanagerconfiguration.h"
|
||||
#include "../../../energyplugin/spotmarket/spotmarketmanager.h"
|
||||
#ifdef ETM_ARBITRATOR
|
||||
#include "../../../energyplugin/etm/energyarbitrator.h"
|
||||
#endif
|
||||
|
||||
#include <jsonrpc/jsonrpcserver.h>
|
||||
#include <loggingcategories.h>
|
||||
@ -81,7 +84,14 @@ void ExperiencePluginEnergyMock::init()
|
||||
EnergyManagerConfiguration *configuration = new EnergyManagerConfiguration(this);
|
||||
QNetworkAccessManager *networkManager = new QNetworkAccessManager(this);
|
||||
m_spotMarketManager = new SpotMarketManager(networkManager, this);
|
||||
// [ETM] BEGIN — flip identique à energypluginnymea.cpp
|
||||
#ifdef ETM_ARBITRATOR
|
||||
qCDebug(dcEnergyExperience()) << "ETM_ARBITRATOR actif — EnergyArbitrator chargé (simulation).";
|
||||
m_smartChargingManager = new EnergyArbitrator(m_energyManager, thingManager(), m_spotMarketManager, configuration, this);
|
||||
#else
|
||||
m_smartChargingManager = new SmartChargingManager(m_energyManager, thingManager(), m_spotMarketManager, configuration, this);
|
||||
#endif
|
||||
// [ETM] END
|
||||
|
||||
m_nymeaEnergyJsonHandler = new NymeaEnergyJsonHandler(m_spotMarketManager, m_smartChargingManager, this);
|
||||
jsonRpcServer()->registerExperienceHandler(m_nymeaEnergyJsonHandler, 0, 2);
|
||||
|
||||
@ -33,6 +33,11 @@ using namespace nymeaserver;
|
||||
|
||||
#include "../../../energyplugin/smartchargingmanager.h"
|
||||
#include "../../mocks/spotmarketprovider/spotmarketdataprovidermock.h"
|
||||
#ifdef ETM_ARBITRATOR
|
||||
#include "../../../energyplugin/etm/energyarbitrator.h"
|
||||
#include "../../../energyplugin/etm/adapters/ecsrelayadapter.h"
|
||||
#include "../../../energyplugin/etm/adapters/sgreadyadapter.h"
|
||||
#endif
|
||||
|
||||
#include <QHash>
|
||||
#include <QtMath>
|
||||
@ -41,11 +46,412 @@ 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
|
||||
// Préambule harnais (comme run()) : DB + (ré)initialisation → expérience chargée et
|
||||
// arbitre FRAIS (pas d'adaptateur ECS résiduel d'un test précédent).
|
||||
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();
|
||||
|
||||
// --- Root meter ---
|
||||
QUuid meterThingId = addMeter();
|
||||
QVERIFY2(!meterThingId.isNull(), "meter thingId invalide");
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
|
||||
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
|
||||
QVERIFY(meterThing);
|
||||
meterThing->setStateValue("connected", true);
|
||||
|
||||
// --- Deux relais ECS : palier 1 = relayA (1200 W), palier 2 = A+B (2400 W) ---
|
||||
QUuid relayAId = addPowerSwitch(1200, 26661);
|
||||
QUuid relayBId = addPowerSwitch(1200, 26662);
|
||||
QVERIFY(!relayAId.isNull() && !relayBId.isNull());
|
||||
Thing *relayA = thingManager->findConfiguredThing(relayAId);
|
||||
Thing *relayB = thingManager->findConfiguredThing(relayBId);
|
||||
QVERIFY(relayA && relayB);
|
||||
|
||||
// minOn = 300 s (protection compresseur) ; minOff = 0 (pas de délai de redémarrage ici).
|
||||
const int minOnS = 300;
|
||||
EcsRelayAdapter *ecs = new EcsRelayAdapter(
|
||||
thingManager, "ecs-surplus-test", "Chauffe-eau (test surplus)",
|
||||
QList<int>({0, 1200, 2400}),
|
||||
QList<QList<QString>>({ {}, {relayAId.toString()}, {relayAId.toString(), relayBId.toString()} }),
|
||||
minOnS, 0, 1, arbitrator);
|
||||
arbitrator->registerEcsAdapter(ecs);
|
||||
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
// currentPower compteur : < 0 = export (surplus PV), > 0 = import (réseau).
|
||||
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
|
||||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||||
|
||||
// ============ Régime 1 — cascade montante sur surplus (export) ============
|
||||
setMeterW(-1000); cycle(t0); // 1000 W < palier 1 → éteint
|
||||
QCOMPARE(ecs->currentStage(), 0);
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), false);
|
||||
|
||||
setMeterW(-1500); cycle(t0); // 1500 W → palier 1 (relayA)
|
||||
QCOMPARE(ecs->currentStage(), 1);
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||||
QCOMPARE(relayB->stateValue("power").toBool(), false);
|
||||
|
||||
setMeterW(-2500); cycle(t0.addSecs(1)); // 2500 W → palier 2 (montée autorisée même sous minOn)
|
||||
QCOMPARE(ecs->currentStage(), 2);
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||||
QCOMPARE(relayB->stateValue("power").toBool(), true);
|
||||
|
||||
// ============ Régime 2 — anti-clignotement (recrédit, hors verrou) ============
|
||||
// T0+400 : minOn écoulé → plancher de verrou = 0. PV 2500, ECS tire 2400 → export net 100.
|
||||
// budget = 100 + 2400 (recrédit conso) = 2500 → RESTE palier 2. Sans recrédit : 100 → éteint.
|
||||
// Le plancher étant 0, c'est bien le RECRÉDIT qui maintient le palier, pas le verrou.
|
||||
setMeterW(-100); cycle(t0.addSecs(400));
|
||||
QCOMPARE(ecs->currentStage(), 2);
|
||||
|
||||
// Redescente propre au palier 1 (import partiel, minOn écoulé) pour préparer le régime 3.
|
||||
setMeterW(600); cycle(t0.addSecs(401)); // import 600 → budget = -600 + 2400 = 1800 → palier 1
|
||||
QCOMPARE(ecs->currentStage(), 1);
|
||||
QCOMPARE(relayB->stateValue("power").toBool(), false);
|
||||
|
||||
// ============ Régime 3 — PROTECTION COMPRESSEUR : import < minOn → RESTE ============
|
||||
// lastSwitch = T0+401. T0+501 (elapsed 100 < minOn 300) : PV 0, ECS tire 1200 → import 1200.
|
||||
// budget = -1200 + 1200 = 0 → le scheduler voudrait palier 0, MAIS plancher de verrou = 1.
|
||||
setMeterW(1200); cycle(t0.addSecs(501));
|
||||
QCOMPARE(ecs->currentStage(), 1); // RESTE allumé — protection compresseur
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||||
|
||||
// ============ Régime 4 — minOn écoulé → DÉLESTE ============
|
||||
// T0+801 (elapsed depuis T0+401 = 400 > minOn 300) : MÊME import 1200. Plancher = 0 → palier 0.
|
||||
// Seul le TEMPS SIMULÉ a changé entre régime 3 et 4 : si le seam de temps était faux
|
||||
// (horloge murale), la décision ne basculerait pas. Ce test prouve le seam ET la protection.
|
||||
setMeterW(1200); cycle(t0.addSecs(801));
|
||||
QCOMPARE(ecs->currentStage(), 0); // DÉLESTE
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), false);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testMeterSilentFallback()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testMeterSilentFallback nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
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();
|
||||
|
||||
// --- Root meter + un relais ECS (palier 1 = 2400 W) ---
|
||||
QUuid meterThingId = addMeter();
|
||||
QVERIFY2(!meterThingId.isNull(), "meter thingId invalide");
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
|
||||
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
|
||||
QVERIFY(meterThing);
|
||||
meterThing->setStateValue("connected", true);
|
||||
|
||||
QUuid relayAId = addPowerSwitch(2400, 26661);
|
||||
QVERIFY(!relayAId.isNull());
|
||||
Thing *relayA = thingManager->findConfiguredThing(relayAId);
|
||||
QVERIFY(relayA);
|
||||
|
||||
// minOn = 300 s (protection compresseur) ; minOff = 0.
|
||||
EcsRelayAdapter *ecs = new EcsRelayAdapter(
|
||||
thingManager, "ecs-fallback-test", "Chauffe-eau (test repli)",
|
||||
QList<int>({0, 2400}),
|
||||
QList<QList<QString>>({ {}, {relayAId.toString()} }),
|
||||
300, 0, 1, arbitrator);
|
||||
arbitrator->registerEcsAdapter(ecs);
|
||||
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
|
||||
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
|
||||
|
||||
// --- ECS allumé sur surplus, compteur frais à T0 ---
|
||||
arbitrator->recordMeterUpdate(t0);
|
||||
setMeterW(-2500); cycle(t0); // surplus 2500 → palier 1 (lastSwitch ECS = T0)
|
||||
QCOMPARE(ecs->currentStage(), 1);
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||||
QVERIFY(!arbitrator->degradedMode());
|
||||
|
||||
// --- Compteur muet > 90 s → mode dégradé ; le repli force=true coupe l'ECS MÊME sous minOn ---
|
||||
// (ECS commuté à T0, elapsed 91 s < minOn 300 → normalement verrouillé ; force=true bypasse.)
|
||||
arbitrator->evaluateMeterFreshness(t0.addSecs(91));
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY(arbitrator->degradedMode());
|
||||
QCOMPARE(ecs->currentStage(), 0); // coupé malgré minOn (sécurité bypasse l'anti-flapping)
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), false);
|
||||
|
||||
// --- STABILITÉ : compteur toujours muet, plusieurs update() → l'ECS RESTE à 0 ---
|
||||
// (planification suspendue : pas de getPlan() sur cache mort qui rallumerait l'ECS.)
|
||||
// On garde même un faux surplus au compteur pour piéger une éventuelle replanification.
|
||||
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(ecs->currentStage(), 0);
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), false);
|
||||
}
|
||||
|
||||
// --- REPRISE : le compteur re-parle → degradedMode retombe → recalcul normal ---
|
||||
arbitrator->recordMeterUpdate(t0.addSecs(300));
|
||||
QVERIFY(!arbitrator->degradedMode());
|
||||
|
||||
// Preuve "recalcul, pas restauration d'ancienne consigne" : surplus FAIBLE → l'ECS
|
||||
// RESTE éteint (recalculé), il ne revient PAS à son ancien palier 1.
|
||||
setMeterW(-1000); cycle(t0.addSecs(301)); // 1000 W < palier 1 → éteint
|
||||
QCOMPARE(ecs->currentStage(), 0);
|
||||
|
||||
// Puis surplus suffisant → l'ECS resuit le surplus normalement.
|
||||
setMeterW(-2500); cycle(t0.addSecs(302));
|
||||
QCOMPARE(ecs->currentStage(), 1);
|
||||
QCOMPARE(relayA->stateValue("power").toBool(), true);
|
||||
#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 : interaction budget PARTAGÉ ECS↔PAC =====================
|
||||
// Surplus 3000 W, ECS palier 1 = 2400 W, PAC P3 = 1500. Selon l'ordre de priorité,
|
||||
// l'un se sert et l'autre voit le RELIQUAT → preuve du waterfall unifié (un seul budget).
|
||||
// priority fixé à la création → on ré-initialise un arbitre frais par ordre testé.
|
||||
auto runSharedBudget = [&](int ecsPrio, int pacPrio, int &ecsStageOut, 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 ke = addPowerSwitch(0, 26663); // relais ECS
|
||||
QUuid j1 = addPowerSwitch(0, 26661); // relais PAC K1
|
||||
QUuid j2 = addPowerSwitch(0, 26662); // relais PAC K2
|
||||
|
||||
// ECS : 1 palier à 2400 W, verrous à 0 (on teste le partage de budget, pas l'anti-rebond).
|
||||
EcsRelayAdapter *ecs = new EcsRelayAdapter(
|
||||
tm2, "ecs-wf", "ECS waterfall",
|
||||
QList<int>({0, 2400}),
|
||||
QList<QList<QString>>({ {}, {ke.toString()} }),
|
||||
0, 0, ecsPrio, arb);
|
||||
arb->registerEcsAdapter(ecs);
|
||||
|
||||
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();
|
||||
ecsStageOut = ecs->currentStage();
|
||||
pacStateOut = pacWf->currentState();
|
||||
};
|
||||
|
||||
int ecsStage = -1, pacState = -1;
|
||||
|
||||
// ECS prioritaire (rang 1) : ECS se sert (2400) → reliquat 600 < P3 → PAC reste en NORMAL (2).
|
||||
runSharedBudget(/*ecsPrio*/ 1, /*pacPrio*/ 2, ecsStage, pacState);
|
||||
QCOMPARE(ecsStage, 1);
|
||||
QCOMPARE(pacState, 2);
|
||||
|
||||
// Priorités INVERSÉES — PAC prioritaire (rang 1) : PAC se sert (état 3, 1500) → reliquat
|
||||
// 1500 < 2400 → l'ECS reste éteint (palier 0). L'ordre de service s'inverse.
|
||||
runSharedBudget(/*ecsPrio*/ 2, /*pacPrio*/ 1, ecsStage, pacState);
|
||||
QCOMPARE(ecsStage, 0);
|
||||
QCOMPARE(pacState, 3);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testEcsRelayTopologies()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testEcsRelayTopologies nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
|
||||
// Setup commun : arbitre frais + root meter. Retourne meter + thingManager via réf.
|
||||
auto freshSetup = [&](EnergyArbitrator *&arb, ThingManager *&tm, Thing *&meter) {
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
tm = NymeaCore::instance()->thingManager();
|
||||
QUuid mId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(mId);
|
||||
meter = tm->findConfiguredThing(mId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
};
|
||||
|
||||
// ===================== Topologie 1 : ECS simple (1 relais, [0, 2000]) =====================
|
||||
{
|
||||
EnergyArbitrator *arb; ThingManager *tm; Thing *meter;
|
||||
freshSetup(arb, tm, meter);
|
||||
QUuid r = addPowerSwitch(2000, 26661);
|
||||
Thing *relay = tm->findConfiguredThing(r);
|
||||
QVERIFY(relay);
|
||||
EcsRelayAdapter *ecs = new EcsRelayAdapter(
|
||||
tm, "ecs-1relay", "ECS simple",
|
||||
QList<int>({0, 2000}),
|
||||
QList<QList<QString>>({ {}, {r.toString()} }),
|
||||
0, 0, 1, arb);
|
||||
arb->registerEcsAdapter(ecs);
|
||||
|
||||
meter->setStateValue("currentPower", -2500); // surplus 2500 → palier 1
|
||||
arb->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||||
QCOMPARE(ecs->currentStage(), 1);
|
||||
QCOMPARE(relay->stateValue("power").toBool(), true);
|
||||
|
||||
meter->setStateValue("currentPower", 1000); // import 1000 → palier 0 (off)
|
||||
arb->simulationCallUpdate(t0.addSecs(1)); QCoreApplication::processEvents();
|
||||
QCOMPARE(ecs->currentStage(), 0);
|
||||
QCOMPARE(relay->stateValue("power").toBool(), false);
|
||||
}
|
||||
|
||||
// ============= Topologie 2 : ECS 3 relais 500/1000/2000 W (mapping NON-CASCADÉ) =============
|
||||
{
|
||||
EnergyArbitrator *arb; ThingManager *tm; Thing *meter;
|
||||
freshSetup(arb, tm, meter);
|
||||
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);
|
||||
|
||||
// 8 niveaux binaires ; encodage bit0=r500, bit1=r1000, bit2=r2000.
|
||||
EcsRelayAdapter *ecs = new EcsRelayAdapter(
|
||||
tm, "ecs-3relay", "ECS 3 relais",
|
||||
QList<int>({0, 500, 1000, 1500, 2000, 2500, 3000, 3500}),
|
||||
QList<QList<QString>>({
|
||||
{}, // 0
|
||||
{r500.toString()}, // 500
|
||||
{r1000.toString()}, // 1000
|
||||
{r500.toString(), r1000.toString()}, // 1500
|
||||
{r2000.toString()}, // 2000
|
||||
{r500.toString(), r2000.toString()}, // 2500
|
||||
{r1000.toString(), r2000.toString()}, // 3000
|
||||
{r500.toString(), r1000.toString(), r2000.toString()}// 3500
|
||||
}),
|
||||
0, 0, 1, arb);
|
||||
arb->registerEcsAdapter(ecs);
|
||||
|
||||
// Surplus 1700 → palier 1500 = [r500, r1000] (r2000 éteint).
|
||||
meter->setStateValue("currentPower", -1700);
|
||||
arb->simulationCallUpdate(t0); QCoreApplication::processEvents();
|
||||
QCOMPARE(ecs->currentStage(), 3);
|
||||
QCOMPARE(t500->stateValue("power").toBool(), true);
|
||||
QCOMPARE(t1000->stateValue("power").toBool(), true);
|
||||
QCOMPARE(t2000->stateValue("power").toBool(), false);
|
||||
|
||||
// Transition NON-CASCADÉE 1500 → 2000 : à stage 3 l'ECS mesure 1500 W (r500+r1000),
|
||||
// export net 700 → budget 700+1500=2200 → palier 2000 = [r2000] SEUL.
|
||||
// Vérifie le set FINAL : r500 OFF, r1000 OFF, r2000 ON (commutation de 3 relais).
|
||||
meter->setStateValue("currentPower", -700);
|
||||
arb->simulationCallUpdate(t0.addSecs(1)); QCoreApplication::processEvents();
|
||||
QCOMPARE(ecs->currentStage(), 4);
|
||||
QCOMPARE(t500->stateValue("power").toBool(), false);
|
||||
QCOMPARE(t1000->stateValue("power").toBool(), false);
|
||||
QCOMPARE(t2000->stateValue("power").toBool(), true);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::run_data()
|
||||
{
|
||||
// Simulation infos
|
||||
|
||||
@ -56,6 +56,22 @@ private slots:
|
||||
void run_data();
|
||||
void run();
|
||||
|
||||
// Waterfall ECS (EcsRelayAdapter) : cascade surplus, anti-clignotement (recrédit),
|
||||
// et protection compresseur (import < minOn → RESTE ; import > minOn → déleste).
|
||||
void testEcsSurplusPV();
|
||||
|
||||
// Watchdog L2 : compteur muet >90 s → mode dégradé (ECS off force=true, bypass minOn),
|
||||
// planification suspendue (ECS reste 0 sur N cycles), reprise au retour du compteur.
|
||||
void testMeterSilentFallback();
|
||||
|
||||
// SG-Ready (PAC) : montée d'états sur surplus, hystérésis 3↔4, protection court-cycling,
|
||||
// et interaction budget PARTAGÉ ECS↔PAC (preuve du waterfall unifié 3e).
|
||||
void testSgReadySurplus();
|
||||
|
||||
// ECS topologies : 1 relais (simple) + 3 relais valeurs différentes (mapping NON-CASCADÉ,
|
||||
// transition 1500→2000 qui commute 3 relais) — vérifie le set de relais final correct.
|
||||
void testEcsRelayTopologies();
|
||||
|
||||
void printStates(Thing *thing);
|
||||
void updateChargerMeter(Thing *thing);
|
||||
|
||||
|
||||
@ -358,6 +358,31 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -474,6 +499,19 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
info->finish(Thing::ThingErrorNoError);
|
||||
}
|
||||
|
||||
|
||||
@ -194,7 +194,7 @@
|
||||
"name": "maxChargingCurrent",
|
||||
"displayName": "Maximum charging current",
|
||||
"displayNameAction": "Set maximum charging current",
|
||||
"type": "uint",
|
||||
"type": "double",
|
||||
"defaultValue":6,
|
||||
"minValue": 6,
|
||||
"maxValue": 32,
|
||||
@ -387,7 +387,7 @@
|
||||
"name": "maxChargingCurrent",
|
||||
"displayName": "Maximum charging current",
|
||||
"displayNameAction": "Set maximum charging current",
|
||||
"type": "uint",
|
||||
"type": "double",
|
||||
"defaultValue":6,
|
||||
"minValue": 6,
|
||||
"maxValue": 32,
|
||||
@ -584,7 +584,7 @@
|
||||
"name": "maxChargingCurrent",
|
||||
"displayName": "Maximum charging current",
|
||||
"displayNameAction": "Set maximum charging current",
|
||||
"type": "uint",
|
||||
"type": "double",
|
||||
"defaultValue":6,
|
||||
"minValue": 6,
|
||||
"maxValue": 32,
|
||||
@ -816,6 +816,49 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user