# TEST_TERRAIN.md — Procédure de test Palier 1 (nymea-dev arm64) Test terrain du moteur **etm-powersync-energy-plugin-etm** sur banc : compteur **mock forçable** (puissance injectée par HTTP) + **relais GPIO réels** (état vérifié via JSON-RPC nymead ou multimètre NO-COM). 14 tests (T1–T14). Chaque test : **inject → log attendu → vérif relais → case ✓/✗**. > Convention puissance compteur : `currentPower < 0` = **export** (surplus PV) ; `> 0` = **import**. > Le moteur calcule un surplus net SIGNÉ `(exportW − importW)` qui cascade par priorité. --- ## §0 — Pré-vol (à remplir SUR LA BOX) | Élément | Valeur | Source | |---|---|---| | IP / hostname | `192.168.1.75` — hostname `hems`, user `etm` (sudo) | briefing terrain | | Port JSON-RPC | `nymeas://hems:2222` (TCP+TLS) · `wss://hems:4444` | connu | | Auth requise ? | `__________` | **[À LIRE SUR LA BOX]** `JSONRPC.Hello` → champ `authenticationRequired` | | Token (si auth) | `__________` | `Users.Authenticate {username,password,deviceName}` → `token` | | ThingClassId compteur mock | `2721a051-6e12-471a-baba-21d87c4cebc9` | connu (energymocks) | | ThingId compteur mock | `__________` | **[À LIRE]** après `Integrations.AddThing`, ou `Integrations.GetThings` | | Port HTTP compteur mock | `26655` | connu | | ThingId R500 (Relay1 C1 BCM5, pin 29) | `8538782f-2c8c-4a30-bfce-c8140f791c9b` | briefing terrain | | ThingId R1000 (Relay2 C2 BCM6, pin 31) | `2ebe6bef-829b-4695-9d11-ddb4c16c5448` | briefing terrain | | ThingId R2000 (Relay3 C3 BCM13, pin 33) | `b033b212-1adb-4df0-ba2b-8fa477de52a2` | briefing terrain | | ThingId K1 (Relay4 C4 BCM16, pin 36) | `beaf92e1-aedc-4b84-9ce4-e423648638cc` | briefing terrain | | ThingId K2 (Relay5 C5 BCM19, pin 35) | `bf236e64-5ae7-4bf1-82a6-4ede03de75a6` | briefing terrain | | gpiochip + BCM offsets | `gpiochip0` ; BCM R500=5, R1000=6, R2000=13, K1=16, K2=19 | vérifiés — **`gpioget` valide uniquement nymead ARRÊTÉ** | | Conteneur build cross-arm64 | `__________` | **[À LIRE DANS `etm-powersync-deploy`/DEPLOY.md]** | > `[À LIRE SUR LA BOX]` = dépend du déploiement, pas inventé ici. Tout le reste est figé. --- ## §1 — Déploiement (option a : build cross-arm64 → scp → dpkg) ### a. Build cross-arm64 (depuis le poste dev) ```bash # Dans le conteneur de build cross-arm64 (cf. DEPLOY.md du repo etm-powersync-deploy). # Produit le .deb arm64 : nymea-energy-plugin-nymea__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 ~/projects/etm-powersync/etm-powersync-deploy/deploy.sh \ powersync-energy-plugin-nymea__arm64.deb --host 192.168.1.75 ssh etm@hems 'journalctl -u nymead -f' # suivre les logs ``` > **Ne PAS installer à la main, et surtout pas par glob.** La recette qui figurait ici — > `scp … /tmp/` puis `sudo dpkg -i /tmp/…_*_arm64.deb` — porte les trois défauts qui ont > **rétrogradé `.75` de `+etm12` à `+etm9` le 2026-08-25** : un glob sur un répertoire qui > accumule, un `dpkg -i` qui autorise la rétrogradation par défaut (`dpkg --force-help` : > `[*] downgrade`), et aucune relecture de la version après coup. `+etm12` a été perdu. > `deploy.sh` archive avant de déposer, installe par nom, refuse une rétrogradation non > demandée et **échoue bruyamment** si la version installée n'est pas celle attendue. > Détail et rollback : `DEPLOY.md` §7.6 du dépôt `etm-powersync-deploy`. > `.so` installé dans `/usr/lib//nymea/energy/libnymea_energypluginnymea.so`. ### c. Activer le logging `[Arbitre]` (SINON aucune trace de décision) La catégorie est exactement **`NymeaEnergy`** (`NYMEA_LOGGING_CATEGORY(dcNymeaEnergy, "NymeaEnergy")`). Méthode robuste — drop-in systemd (Qt logging rules) : ```bash ssh etm@$BOX 'sudo mkdir -p /etc/systemd/system/nymead.service.d && \ printf "[Service]\nEnvironment=QT_LOGGING_RULES=NymeaEnergy.debug=true\n" | \ sudo tee /etc/systemd/system/nymead.service.d/etm-logging.conf >/dev/null && \ sudo systemctl daemon-reload && sudo systemctl restart nymead' ``` > Alternative selon la version : `nymead --logging ` 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]`, `[RelayRouter]`, `[EtmVariableLoadAdapter]` et `[SgReadyAdapter]`. ### d. Déclarer les charges — par CONFIGURATION, jamais par recompilation **Depuis le lot B-bis, il n'existe plus AUCUN adaptateur codé en dur.** `energypluginnymea.cpp::init()` crée l'`EnergyArbitrator` et lui branche le `LoadConfigStore` — et n'enregistre rien d'autre. Toute charge arbitrée vient de la configuration persistée, déclarée par `NymeaEnergy.SetLoadConfig`. La recette « coller un bloc C++ puis rebuild » qui figurait ici est **morte** : elle ne compile plus (`registerEcsAdapter()`/`registerSgReadyAdapter()` n'existent plus), et elle produisait une charge arbitrée que `GetLoadConfig` ignorait. Ce que ça change pour le banc : - changer un palier, un rang, un ThingId ou un `minStateHoldS` = **un appel RPC**, plus un cycle build cross-arm64 → `.deb` → `deploy.sh` → redémarrage de `nymead` ; - `GetLoadTelemetry` publie **exactement** l'ensemble de `GetLoadConfig` (contrat `INTERFACE.md`) ; - la configuration **survit** au redéploiement : elle est persistée hors du paquet. Workflow : déployer → `Integrations.AddThing` le compteur mock + les relais GPIO → relever les ThingId (`GetThings`) → **un** `SetLoadConfig` avec les deux charges → `LoadConfigChanged` est émise, les adaptateurs sont (re)construits à chaud. > ⚠️ **`SetLoadConfig` REMPLACE l'ensemble** et se refuse **en bloc** si une entrée est invalide. > Toujours envoyer **les deux** charges dans le même appel — et lire `GetLoadConfig` avant, pour > partir de l'existant. Envoyer la seule PAC **supprimerait** `chauffe-eau` (qui passerait alors > par `applySafeState()` avant destruction, ECS-413 : relais ouverts). ```bash # Depuis .75 (client JSON-RPC minimal, cf. §11 de docs/RELEVE_LOTB.md). python3 /tmp/nymea_rpc.py call NymeaEnergy.SetLoadConfig '{"loadConfigs": [ {"id": "chauffe-eau", "label": "chauffe-eau", "adapter": "relay-router", "mode": "fixed", "priority": 1, "enabled": true, "minOnS": 60, "minOffS": 60, "relays": [{"thingId": "{8538782f-2c8c-4a30-bfce-c8140f791c9b}", "powerW": 500}, {"thingId": "{2ebe6bef-829b-4695-9d11-ddb4c16c5448}", "powerW": 1000}, {"thingId": "{b033b212-1adb-4df0-ba2b-8fa477de52a2}", "powerW": 2000}]}, {"id": "pac-terrain", "label": "PAC banc", "adapter": "sg-ready", "mode": "fixed", "priority": 2, "enabled": true, "sgReady": {"minStateHoldS": 300, "states": [ {"state": 1, "relays": ["{beaf92e1-aedc-4b84-9ce4-e423648638cc}"], "estimatedPowerW": 0}, {"state": 2, "relays": [], "estimatedPowerW": 0}, {"state": 3, "relays": ["{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}"], "estimatedPowerW": 1500}, {"state": 4, "relays": ["{beaf92e1-aedc-4b84-9ce4-e423648638cc}", "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}"], "estimatedPowerW": 3000}]}} ]}' ``` > L'**état 2 doit être exprimable** — c'est le repli sûr du mode dégradé L2. Une config > sg-ready qui ne le déclare pas est **refusée** par `LoadConfig::isValid()` (ECS-110 : un > refus explicite, jamais un `Q_ASSERT` absent du binaire release). > Le **root meter** = le compteur mock : le déclarer via l'expérience énergie nymea > (`Energy.SetRootMeter`) avec son ThingId. > Pour un ECS à un seul relais (T1/T2) : ne garder que l'entrée `{R2000, 2000}` dans `relays`. #### ⚠️ Les puissances SG-Ready de ce bloc sont PROVISOIRES et NON MESURÉES `estimatedPowerW` **{état 3 : 1500 W, état 4 : 3000 W} ne décrit aucune machine.** Ces deux nombres viennent du bloc codé en dur supprimé au lot B-bis, où ils décrivaient une PAC **inventée** pour faire tourner l'arbitrage. Au 2026-08-25, **aucune PAC n'est sous tension sur K1/K2** : la polarité des contacts est le seul point de la chaîne encore non vérifié (§3.5, cases non cochées). Les reconduire ici les rend calculables pour §4 — **pas véridiques**. **À remplacer par les valeurs de la machine réellement câblée** (plaque signalétique, puis mesure sous les deux régimes) avant toute conclusion transposable à une installation. Ce n'est pas cosmétique : `estimatedPowerW` sert de base au **recrédit budget** et au **choix d'état** ; une estimation fausse ne fausse pas seulement la PAC, elle décale l'allocation de **tout le waterfall** en aval. Le champ porte son nom — une estimation, jamais un engagement — mais une estimation reste censée viser la bonne machine. `minStateHoldS: 300` reconduit également la valeur du banc. Une PAC réelle demande plutôt **900 s** (protection compresseur) ; à cette durée, **toute reconstruction d'adaptateur réarme le verrou pour 900 s** (ECS-412 : un `m_lastSwitch` nul vaut « commutation venant d'avoir lieu », jamais « jamais commuté »). L'armement est **paresseux** — la première décision reçue pose l'estampille et le compte à rebours court — mais sur une PAC réelle il faut savoir que changer le matériel d'une charge en configuration lui coûte un cycle de verrou. #### Migration `+etm14` → `+etm15` : l'ORDRE compte (contacts K1/K2) La suppression du bloc codé en dur est un événement de **compilation**, pas d'exécution : `applySafeState()` (ECS-413) ne peut pas s'appliquer à un objet que le nouveau binaire ne construit jamais. Le filet est donc **procédural** : 1. **Poser la config sg-ready d'abord**, sur le `+etm14` en cours d'exécution. L'exemplaire en dur cède les contacts à l'adaptateur de configuration (LM-300) : K1/K2 ne sont jamais sans propriétaire. 2. **Puis** `deploy.sh` le `+etm15`. Au démarrage, la config est déjà là : l'adaptateur est reconstruit et **relit les contacts** (ECS-411) au lieu de se supposer à l'état 2. Installer d'abord et configurer ensuite laisse une fenêtre où **plus personne ne commande K1/K2** : ils restent dans leur dernier état commandé — possiblement fermés, c'est-à-dire PAC en forcé — et rien ne les arbitre. `enabled: false` ne met pas davantage une charge en sécurité : il la retire de la configuration, et seul ECS-413 (charge qui **sort** d'une config vivante) passe par l'état sûr. > Si l'ordre a été rompu : `nymead` arrêté, vérifier K1/K2 par le pré-vol §3.5 > (`gpioget -c gpiochip0 16 19` → attendu `0 0`), ou poser la config immédiatement — le premier > cycle d'arbitrage reprend la main sur les contacts. --- ## Helpers bash (poste dev) ```bash BOX=hems; MPORT=26655 # ThingIds relais (banc hems — pré-vérifiés, cf. §0) TID_R500="8538782f-2c8c-4a30-bfce-c8140f791c9b" TID_R1000="2ebe6bef-829b-4695-9d11-ddb4c16c5448" TID_R2000="b033b212-1adb-4df0-ba2b-8fa477de52a2" TID_K1="beaf92e1-aedc-4b84-9ce4-e423648638cc" TID_K2="bf236e64-5ae7-4bf1-82a6-4ede03de75a6" # Injecter une puissance compteur (W). Négatif = export (surplus PV), positif = import. inject(){ curl -s "http://$BOX:$MPORT/setstates?connected=true¤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; } # Suivre les décisions de l'arbitre. # SANS sudo : `etm` est dans le groupe `adm`. Avec sudo, la commande échoue faute de TTY, # l'erreur part sur stderr, stdout est VIDE — et le suivi paraît simplement silencieux. # Un essai conduit sur ce flux ne dit rien et en a l'air (AGENTS.md, « Lire le journal »). logs(){ ssh etm@$BOX "journalctl -u nymead -f | grep -E 'Arbitre|RelayRouter|EtmVariableLoad|SgReady'"; } ``` ```bash # relay → 1 (ON) | 0 (OFF) via JSON-RPC local nymead. # Protocole complet : Hello → auth si requise → GetThingClasses (résolution stateTypeId # 'power') → GetThings. Framing : boucle par id — ignore les notifications push nymea. # gpioget inutilisable nymead actif (libgpiod v2 busy). Filet : multimètre NO-COM nymead arrêté. # Variables optionnelles : NYMEA_USER (défaut admin) NYMEA_PASS (défaut vide). # ACCEPTANCE avant T1 : forcer un relais ON via l'app nymea, vérifier relay $TID_Rxx → 1 ; # forcer OFF → 0. Tant que ce test isolé ne passe pas, la colonne "vérif" de T1–T14 est aveugle. relay(){ local tid="$1" local nu="${NYMEA_USER:-admin}" np="${NYMEA_PASS:-}" ssh etm@$BOX "python3 - '$nu' '$np'" << GETSTATE import ssl, socket, json, sys user, pw = sys.argv[1], sys.argv[2] ctx = ssl.create_default_context() ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE buf = b'' def call(s, rid, method, params=None, token=None): global buf req = {'id': rid, 'method': method} if params: req['params'] = params if token: req['token'] = token s.sendall((json.dumps(req) + '\n').encode()) while True: buf += s.recv(4096) lines = buf.split(b'\n'); buf = lines[-1] for ln in lines[:-1]: ln = ln.strip() if not ln: continue try: m = json.loads(ln) if m.get('id') == rid: return m except: pass with socket.create_connection(('localhost', 2222), timeout=10) as raw, \ ctx.wrap_socket(raw) as s: hello = call(s, 1, 'JSONRPC.Hello') token = None if hello.get('params', {}).get('authenticationRequired', False): r = call(s, 2, 'Users.Authenticate', {'username': user, 'password': pw, 'deviceName': 'relay-chk'}) p = r.get('params', {}) if not p.get('success', True) or 'token' not in p: sys.exit('AUTH_FAILED — définir NYMEA_USER / NYMEA_PASS') token = p['token'] # Résoudre stateTypeId de 'power' pour chaque ThingClass (les states dans GetThings # ne portent pas le nom, seulement le stateTypeId — cf. nymea JSON-RPC spec). tc = call(s, 3, 'Integrations.GetThingClasses', token=token) power_stid = {} for c in tc.get('params', {}).get('thingClasses', []): for st in c.get('stateTypes', []): if st.get('name') == 'power': power_stid[c['id']] = st['id'] resp = call(s, 4, 'Integrations.GetThings', token=token) things = resp.get('params', {}).get('things', []) t = next((x for x in things if x['id'] == '${tid}'), None) if not t: sys.exit('THING_NOT_FOUND: ${tid}') stid = power_stid.get(t.get('thingClassId', ''), '') state = next((st for st in t.get('states', []) if st.get('stateTypeId') == stid), None) print(1 if state and state.get('value') else 0) GETSTATE } ``` > Le compteur mock pose `currentPower = somme des 3 phases`. `RootMeter::currentPower()` le relit. --- ## §2 — ECS simple (1 relais, paliers {0, 2000}) ### T1 — Montée sur surplus - **inject** : `inject -2500` - **log attendu** : `[Arbitre] … Surplus PV … ECS palier 1` puis `[RelayRouter] chauffe-eau → consigne … W → palier 500 W` - **vérif** : `relay $TID_R2000` → **1** (ON) - ✓ / ✗ : `____` ### T2 — Délestage (import) - **inject** : `inject 1000` (import → surplus net négatif) - **log attendu** : `Surplus insuffisant … ECS éteint` ; `→ stage 0` - **vérif** : `relay $TID_R2000` → **0** (OFF) - ✓ / ✗ : `____` --- ## §3 — ECS 3 relais (8 niveaux binaires R500/R1000/R2000) ### T3 — Cascade montante - **inject** : `inject -1700` (budget 1700 → palier **1500**) - **log** : `ECS palier 3 (1500 W)` - **vérif** : `relay $TID_R500`→**1**, `relay $TID_R1000`→**1**, `relay $TID_R2000`→**0** - ✓ / ✗ : `____` ### T4 — Transition NON-CASCADÉE 1500 → 2000 (le test clé) - **contexte** : on part de T3 (palier 1500, l'ECS mesure 1500 W). - **inject** : `inject -700` (budget = 700 + 1500 recrédit = 2200 → palier **2000**) - **log** : `ECS palier 4 (2000 W)` ; commutation **off-before-on** (R500/R1000 coupés avant R2000) - **vérif** : `relay $TID_R500`→**0**, `relay $TID_R1000`→**0**, `relay $TID_R2000`→**1** ← *set final R2000 SEUL, pas un état parasite durable* - ✓ / ✗ : `____` ### T5 — Protection minOn (anti court-cycling) - **contexte** : ECS vient de commuter (< minOn 60 s). - **inject** : `inject 1200` (import → le budget voudrait éteindre) - **log attendu** : `Verrou minOn — … maintenu palier …` - **vérif** : relais **inchangés** (maintien). Attendre > 60 s puis re-`inject 1200` → délestage. - ✓ / ✗ : `____` ### T6 — Délestage complet - **inject** : `inject 2000` (import franc, > minOn écoulé) - **vérif** : `relay $TID_R500`→**0**, `relay $TID_R1000`→**0**, `relay $TID_R2000`→**0** - ✓ / ✗ : `____` --- ## §3.5 — Pré-vol SG-Ready (**à cocher avant T7, nymead ARRÊTÉ**) > ⚠️ La polarité K1/K2 est le **seul point non vérifié proprement** de toute la chaîne. > Le test initial a échoué sur `Device or resource busy` (nymead tient les lignes GPIO via > libgpiod v2). Cette vérification doit se faire **nymead arrêté**, au multimètre, avant > toute mise sous tension côté PAC. ```bash ssh etm@$BOX 'sudo systemctl stop nymead' ssh etm@$BOX 'gpioget -c gpiochip0 16 19' # attendu : 0 0 (repos LOW BCM16/BCM19) ``` - [ ] `gpioget` répond **0 0** — K1 (BCM16) et K2 (BCM19) au repos LOW. ✓ - [ ] **K1 repos = circuit ouvert** : multimètre en continuité NO-COM sur bornes K1 → **circuit ouvert** = PAC interprète 0:0 = **état 2 « normal »** (jamais 1:0 = blocage au boot). ✓ - [ ] **K2 repos = circuit ouvert** (idem). ✓ - [ ] **Fail-safe boot confirmé** : nymead démarre K1=K2=0 (repos LOW, NO/NC → circuit ouvert → PAC = normal). ✓ - [ ] `sudo systemctl start nymead` avant T7. --- ## §4 — SG-Ready (PAC, 2 bits K1/K2 ; P3=1500, P4=3000) > Les seuils ci-dessous se DÉDUISENT de la configuration posée en §1.d — ils ne sont pas > gravés dans le moteur. P3/P4 sont les `estimatedPowerW` déclarés, **provisoires et non > mesurés** (§1.d) : changer la config change les seuils, et il n'y a rien à recompiler. > **Seuils code** (`rulebasedscheduler.cpp`, `kForceMargin=1.2`, P3=1500, P4=3000) : > - État 3 si budgetW ≥ 1500 ; état 4 si budgetW ≥ P4×1,2 = **3600** ; zone morte état 4 si budgetW ≥ 3000. > - `budgetW = exportW_brut + allocatedNow` ; allocatedNow = 0 (état 2), 1500 (état 3), 3000 (état 4). > - Depuis état 2 (sans recrédit) : min. inject **≤ −3600** pour atteindre état 4 directement. > - Depuis état 3 (recrédit 1500) : min. inject **≤ −2100** pour atteindre état 4. ### T7 — Montée d'états 2 → 3 → 4 - `inject -1000` → état **2** (budget 1000 < 1500, K1=0,K2=0). - `inject -2000` → état **3** (budget 2000 ≥ 1500, K2=1). Attendre > minStateHold (300 s). - `inject -2500` → état **4** (budget = 2500 + 1500 recrédit = **4000 ≥ 3600**, K1=1,K2=1). - **log** : `… recommandée (état 3 …)` puis `… forcée (état 4 …)` - **vérif** : état 3 → `relay $TID_K1`→**0**, `relay $TID_K2`→**1** ; état 4 → `relay $TID_K1`→**1**, `relay $TID_K2`→**1** - ✓ / ✗ : `____` ### T8 — Atomicité (transitoire bénin) - **contexte** : transition 2→4 (00→11) commute K1 ET K2. - **vérif** : set **final** `relay $TID_K1`→**1**, `relay $TID_K2`→**1** (état 4). Le transitoire passe par K2 d'abord (01=reco), **jamais** 10=blocage — sub-ms, non observable, mais **garanti code** (`transientHarm`). Confirmer simplement le set final correct. - ✓ / ✗ : `____` ### T9 — Zone morte hystérésis (pas de bascule 4→3 sur fluctuation surplus) - **contexte** : PAC en état 4 (K1=K2=1) depuis > minStateHold (300 s). L'opérateur injecte le **surplus brut au compteur** — le moteur recrédite la puissance allouée (3000 W déclarés) en interne ; ne pas l'ajouter à `inject`. - Faire fluctuer le surplus : `inject -300` → reste **4** ; `inject -500` → reste **4** ; `inject -100` → reste **4** ; `inject 0` (équilibré) → reste **4** (tout inject ≤ 0 tient l'état). - **Sortie** : `inject 300` (import net) → **état 3**. - **vérif** : `relay $TID_K1`→**1**, `relay $TID_K2`→**1** stable pendant la fluctuation ; `relay $TID_K1`→**0** uniquement après `inject 300`. - ✓ / ✗ : `____` --- ## §5 — Watchdog L2 (compteur muet) ### T10 — Compteur muet → mode dégradé - **inject** `-2500` (ECS/PAC servis), puis **`mute`** (>90 s sans injection). - **log attendu** : `[Arbitre] Compteur muet depuis … mode dégradé L2` ; ECS palier 0 ; PAC **état 2**. - **vérif** : `relay $TID_R500`→**0**, `relay $TID_R1000`→**0**, `relay $TID_R2000`→**0** ; `relay $TID_K1`→**0**, `relay $TID_K2`→**0** (état 2, **jamais** blocage). - ✓ / ✗ : `____` ### T11 — Stabilité (faux surplus piège) - **contexte** : toujours muet ; la **dernière** valeur injectée (-2500) reste « collée » au compteur. - **vérif** : sur plusieurs minutes muettes, ECS **reste 0** malgré ce surplus stale (planification suspendue — pas de replanif sur cache mort). - ✓ / ✗ : `____` ### T12 — Reprise - **inject** `-2500` (le compteur re-parle). - **log attendu** : `Compteur de nouveau actif — sortie du mode dégradé` ; recalcul normal. - **vérif** : ECS resuit le surplus (palier > 0). Pas de restauration d'ancienne consigne. - ✓ / ✗ : `____` --- ## §6 — Interaction budget partagé ### T13 — Ordre de priorité ECS↔PAC inversable - **contexte** : surplus moyen `inject -3000`, ECS palier 2400 vs PAC P3 1500. - **ECS prio 1 / PAC prio 2** : ECS se sert (palier), PAC voit le reliquat (600) → **état 2**. - **Inverser les priorités** (échanger `priority` dans les deux entrées, un `SetLoadConfig` — ni rebuild ni redéploiement depuis le lot B-bis) → PAC se sert (état 3), ECS voit le reliquat (1500 < 2400) → **palier 0**. - **vérif** : le service s'inverse selon la priorité (preuve du waterfall unifié). - ✓ / ✗ : `____` --- ## §7 — OPTIONNEL EV / V2C (si plugin prêt) ### T14 — Ordre étagé EV → ECS (beta : EV servi avant le waterfall) - **contexte** : borne EV branchée + surplus moyen. - **attendu** : l'EV est servi **en premier** (proxy amont, décision B), l'ECS voit le reliquat. - **vérif** : courant EV puis palier ECS sur le reliquat. *(V2C = session dédiée, hors Palier 1.)* - ✓ / ✗ : `____` --- ## Checklist sécurité (AVANT mise sous tension) - [ ] **Disjoncteur L0** du banc **accessible** et identifié (coupure physique immédiate). - [ ] Banc câblé hors tension ; sections/calibres relais conformes aux puissances (R2000 = 2000 W). - [ ] **Polarité SG-Ready vérifiée** (§3.5 fait et coché) — seul point non vérifié avant cette session. - [ ] Relais résistifs ECS : pas de charge inductive sur ces voies. - [ ] Mapping BCM↔relais consigné (`gpioinfo gpiochip0`, nymead arrêté) avant tout test. - [ ] L1 (failsafe bornes/relais) configuré si applicable ; L0 reste le filet ultime. - [ ] Un opérateur à la main sur le disjoncteur pendant T1–T6 (montées de puissance). --- ## §12 — CE QUE LE BANC N'A PAS PROUVÉ, et qu'on citera pourtant comme preuve > Relevé le 2026-09-06, au moment où `.75` est passée à de vrais compteurs Eastron. > **À lire avant de citer une validation §11 comme une preuve de bout en bout.** ### Le constat `ECS-Meter` et `PAC-Meter` sont des Things `smartMeterConsumer` du plugin **generic-energy**, appairés à la main. Leur `currentPower` n'est pas mesuré : il est **saisi**, par action RPC ou par un script de banc. Jusqu'au 2026-09-05 il valait 1 500 W et 800 W, constants ; depuis le passage au réel il vaut **0**, faute de quelqu'un pour l'écrire. **Conséquence** : tout le §11 — compteur dédié par charge, `sessionWh` alimenté depuis le compteur, `measurement.source: "meter"` — a été validé contre des **valeurs tapées, jamais mesurées**. ### Ce qui est prouvé, et ce qui ne l'est pas | | | |---|---| | **Prouvé** | la PLOMBERIE. Un `meterThingId` est résolu, l'état est lu, la valeur remonte dans `measurement.powerW`, `sessionWh` s'alimente, `source` vaut `meter`. La chaîne existe et transporte. | | **NON prouvé** | la MESURE. Qu'un compteur réel, rattaché à une charge réelle, produise une valeur qui **suit la commande** — donc que l'écart commandé↔mesuré signifie quelque chose. | **La distinction n'est pas académique** : c'est exactement celle qui rend les traces de divergence inexploitables sur ces deux charges (`DESIGN_COMMANDE_PERDUE.md` §6). Un compteur constant — à 1 500 W hier, à 0 aujourd'hui — produit un écart qui ne parle que de sa propre constance. ### Ce que ça explique côté écran Une consommation affichée sur des compteurs qui ne mesurent rien. L'app est fidèle : elle publie ce que la box lui dit, et la box lit fidèlement un état que personne ne met à jour. **Aucun des deux n'a tort ; la chaîne est juste sans source.** ### Ce qu'il faudrait pour lever la réserve Un compteur RÉEL sur au moins une charge pilotée — un SDM120Consumer sur l'ECS ferait l'affaire, le plugin `eastron` porte déjà la classe. Tant que ce n'est pas fait : - ne pas citer les validations §11 comme des preuves de bout en bout ; - ne compter aucune divergence sur `chauffe-eau` ni `pac-terrain` ; - se souvenir que c'est le **sixième manque du mock** (`INVENTAIRE_MOCK.md` §7), nommé une troisième fois par un troisième chemin : d'abord par les traces, puis par la pose d'obligation, maintenant par le passage au réel.