Patrick Schurig 4259faee5b docs(terrain): §11 a été validé contre des valeurs tapées, jamais mesurées
ECS-Meter et PAC-Meter sont des smartMeterConsumer de generic-energy appairés à la
main : leur currentPower est SAISI, pas mesuré. 1 500 W et 800 W constants jusqu'au
2026-09-05, puis 0 depuis le passage de la box aux vrais compteurs Eastron, faute
de quelqu'un pour l'écrire.

Donc tout le §11 — compteur dédié par charge, sessionWh depuis le compteur,
measurement.source "meter" — a été éprouvé contre des valeurs tapées.

CE QUI EST PROUVÉ : la plomberie. meterThingId résolu, état lu, valeur remontée
dans measurement.powerW, sessionWh alimenté, source = meter. La chaîne existe et
transporte.

CE QUI NE L'EST PAS : 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 celle qui rend les traces de divergence
inexploitables sur ces deux charges. Un compteur constant produit un écart qui ne
parle que de sa propre constance.

Et ça explique ce que voit l'app : une consommation affichée sur des compteurs qui
ne mesurent rien. Elle est fidèle, la box aussi — la chaîne est juste sans source.

Troisième nomination du sixième manque du mock, par un troisième chemin : les
traces de divergence, puis la pose d'obligation, maintenant le passage au réel.
2026-09-06 12:47:50 +02:00

471 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_<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
~/projects/etm-powersync/etm-powersync-deploy/deploy.sh \
powersync-energy-plugin-nymea_<ver>_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/<multiarch>/nymea/energy/libnymea_energypluginnymea.so`.
### c. Activer le logging `[Arbitre]` (SINON aucune trace de décision)
La catégorie est exactement **`NymeaEnergy`** (`NYMEA_LOGGING_CATEGORY(dcNymeaEnergy, "NymeaEnergy")`).
Méthode robuste — drop-in systemd (Qt logging rules) :
```bash
ssh etm@$BOX 'sudo mkdir -p /etc/systemd/system/nymead.service.d && \
printf "[Service]\nEnvironment=QT_LOGGING_RULES=NymeaEnergy.debug=true\n" | \
sudo tee /etc/systemd/system/nymead.service.d/etm-logging.conf >/dev/null && \
sudo systemctl daemon-reload && sudo systemctl restart nymead'
```
> Alternative selon la version : `nymead --logging <règles>` ou la section logging de
> `/etc/nymea/nymead.conf`. **[VÉRIFIER `nymead --help` SUR LA BOX]** si le drop-in ne suffit pas.
> Les lignes attendues commencent par `[Arbitre]`, `[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&currentPowerPhaseA=$(($1/3))&currentPowerPhaseB=$(($1/3))&currentPowerPhaseC=$(($1/3))" >/dev/null; echo "compteur = $1 W"; }
# Compteur MUET : on cesse d'injecter (>90 s) → watchdog L2 bascule (QTimer 30 s, seuil 90 s).
mute(){ echo "NE PLUS injecter pendant >90 s…"; sleep 95; }
# Suivre les décisions de l'arbitre.
# 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 <ThingId> → 1 (ON) | 0 (OFF) via JSON-RPC local nymead.
# Protocole complet : Hello → auth si requise → GetThingClasses (résolution stateTypeId
# 'power') → GetThings. Framing : boucle par id — ignore les notifications push nymea.
# gpioget inutilisable nymead actif (libgpiod v2 busy). Filet : multimètre NO-COM nymead arrêté.
# Variables optionnelles : NYMEA_USER (défaut admin) NYMEA_PASS (défaut vide).
# ACCEPTANCE avant T1 : forcer un relais ON via l'app nymea, vérifier relay $TID_Rxx → 1 ;
# forcer OFF → 0. Tant que ce test isolé ne passe pas, la colonne "vérif" de 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.