Trois sondes permanentes, et le relevé de ce qu'elles ont montré sur .75. - telemetry_watch.dart (lecture seule) : cadence du battement de cœur et avancée du cycle. C'est la seule mesure qui distingue une installation stable d'un moteur arrêté — les deux produisent le même flux de trames. - domain_assign.dart (écrit, --yes) : classe des charges en important le code d'écriture de l'app. Une sonde qui sérialiserait à sa façon ne prouverait rien sur l'app. - fault_probe.dart (écrit, --yes) : provoque une charge en défaut, éprouve ClearLoadFault, restaure. La config d'origine part sur le disque AVANT la première écriture et la restauration rejoue ce fichier verbatim — filtrer la sonde hors d'une relecture serait une reconstruction, c'est-à-dire le chemin qui a failli écraser la config du banc via persist(). La config relue est ensuite comparée clé par clé, pas seulement « la sonde a disparu ». Ce que le banc a appris, et qui ne se déduisait pas du code : - ClearLoadFault lève RÉELLEMENT le verrou (journal : « défaut LEVÉ ») et le cycle suivant reverrouille, la cause n'ayant pas disparu. La télémétrie ne repasse jamais à available:true. Une app qui aurait cru l'acquittement aurait menti deux secondes plus tard. - Le défaut publié est WRITE_FAILED, pas THING_MISSING : l'écriture est tentée, échoue, l'échelle s'épuise, et m_faulted masque le second code. - SetLoadConfig refuse en bloc et le motif n'est PAS dans la réponse RPC — il est au journal. L'app ne peut que rapporter un refus, pas l'expliquer. - ECS-412 confirmé au journal : une écriture de domaine ne reconstruit aucun adaptateur (« 2 inchangée(s), 0 retirée(s) »). Le §7-1bis n'est plus une lecture de code. .75 est laissé classé (chauffe-eau→ecs, pac-terrain→hvac) : métadonnée que l'arbitre ne lit pas, et le multi-domaines y devient exerçable. Retour arrière : domain_assign.dart --clear --yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
273 lines
12 KiB
Dart
273 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
// §7-7 — provoquer une charge `available: false` sur le banc, et regarder ce que
|
|
// `ClearLoadFault` en fait vraiment.
|
|
//
|
|
// ⚠️ CE SCRIPT ÉCRIT SUR LA BOX, ET IL AJOUTE UNE CHARGE FICTIVE.
|
|
// Le drapeau --yes est obligatoire.
|
|
//
|
|
// ## Pourquoi une charge fictive
|
|
//
|
|
// Les deux défauts réellement atteignables ne se provoquent pas de la même façon :
|
|
//
|
|
// * `WRITE_FAILED` — l'échelle d'écriture est épuisée : il faut du matériel qui refuse
|
|
// les commandes. **Pas provoquable depuis le réseau.** C'est pourtant le SEUL défaut
|
|
// que `clearFault()` lève (`m_faulted`).
|
|
// * `THING_MISSING` — l'adaptateur `etmvariableload` ne trouve pas le Thing qui porte
|
|
// son propre `id` (`etmvariableloadadapter.cpp:149`). Provoquable, puisqu'il suffit
|
|
// de nommer un ThingId absent — et c'est le seul des trois codes qui s'obtienne sans
|
|
// toucher au matériel ni casser une liaison réelle.
|
|
//
|
|
// La charge fictive est **en défaut**, donc exclue de l'arbitrage : elle ne consomme
|
|
// aucun budget, et son ThingId n'existant pas, elle ne peut commander aucun contact. Le
|
|
// pire cas est une charge inerte dans la configuration.
|
|
//
|
|
// Ce que la sonde établit :
|
|
// (a) une charge en défaut sort de l'arbitrage avec `available: false` et un `faultCode`
|
|
// que l'app doit rendre en français ;
|
|
// (b) `ClearLoadFault` sur un `THING_MISSING` **ne lève rien** — l'adaptateur renvoie
|
|
// `false`, l'arbitre journalise « sans objet », et le RPC répond quand même
|
|
// `EnergyErrorNoError`. C'est exactement pourquoi l'app n'annonce jamais une levée
|
|
// sur le retour du RPC.
|
|
//
|
|
// ## La restauration
|
|
//
|
|
// La configuration lue au départ est écrite **dans un fichier local avant la première
|
|
// écriture**, et c'est cette map qui est renvoyée verbatim. Jamais une relecture filtrée :
|
|
// retirer la sonde d'une config relue serait une **reconstruction**, c'est-à-dire le
|
|
// chemin exact qui a failli écraser la configuration du banc (`EnergySetupProvider
|
|
// .persist()`). Et un plantage à mi-parcours ne doit pas laisser le banc avec la charge
|
|
// fictive et aucun moyen de savoir ce qu'il y avait avant : le fichier survit au script.
|
|
//
|
|
// Après restauration, la sonde ne se contente pas de vérifier que la charge fictive a
|
|
// disparu : elle compare `GetLoadConfig` à ce qu'elle avait lu au départ, clé par clé.
|
|
// C'est le test d'aller-retour neutre du critère 1, appliqué ici.
|
|
//
|
|
// Usage :
|
|
// dart run tools/rpc/fault_probe.dart <host> --yes
|
|
// dart run tools/rpc/fault_probe.dart <host> --restore-only --yes
|
|
// (--backup <chemin> pour changer le fichier de sauvegarde)
|
|
// (--dump <préfixe> pour écrire les charges utiles BRUTES de GetLoadTelemetry —
|
|
// c'est ainsi qu'on obtient une fixture de défaut sans avoir à reprovoquer le défaut
|
|
// une seconde fois : une charge utile recopiée d'un affichage n'est pas un dump.)
|
|
const String kProbeLoadId = '{00000000-dead-4bee-8000-000000000042}';
|
|
|
|
/// Sans ambiguïté possible : si cette charge survit à un incident, personne ne doit se
|
|
/// demander d'où elle sort dans trois semaines.
|
|
const String kProbeLabel = 'SONDE-TEST-A-SUPPRIMER';
|
|
|
|
Future<void> main(List<String> args) async {
|
|
if (!args.contains('--yes')) {
|
|
stderr.writeln('Ce script ÉCRIT sur la box et ajoute une charge fictive.');
|
|
stderr.writeln('Relancer avec --yes pour confirmer.');
|
|
exitCode = 2;
|
|
return;
|
|
}
|
|
final restoreOnly = args.contains('--restore-only');
|
|
final host =
|
|
args.isNotEmpty && !args.first.startsWith('--') ? args.first : '192.168.1.75';
|
|
|
|
final di = args.indexOf('--dump');
|
|
final dumpPrefix = (di >= 0 && di + 1 < args.length) ? args[di + 1] : null;
|
|
|
|
final bi = args.indexOf('--backup');
|
|
final backupPath = (bi >= 0 && bi + 1 < args.length)
|
|
? args[bi + 1]
|
|
: 'fault_probe_backup_$host.json';
|
|
final backupFile = File(backupPath);
|
|
|
|
final ws = await WebSocket.connect('ws://$host:4444')
|
|
.timeout(const Duration(seconds: 8));
|
|
var id = 0;
|
|
final pending = <int, Completer<Map<String, dynamic>>>{};
|
|
ws.listen((raw) {
|
|
final m = jsonDecode(raw as String) as Map<String, dynamic>;
|
|
final rid = m['id'];
|
|
if (rid is int && pending.containsKey(rid)) pending.remove(rid)!.complete(m);
|
|
});
|
|
Future<Map<String, dynamic>> call(String me, [Map<String, dynamic> p = const {}]) {
|
|
final i = ++id;
|
|
final c = Completer<Map<String, dynamic>>();
|
|
pending[i] = c;
|
|
ws.add(jsonEncode({'id': i, 'method': me, 'params': p}));
|
|
return c.future.timeout(const Duration(seconds: 20));
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> readConfig() async {
|
|
final r = await call('NymeaEnergy.GetLoadConfig');
|
|
return (r['params']['loadConfigs'] as List)
|
|
.cast<Map<String, dynamic>>()
|
|
.map((m) => Map<String, dynamic>.from(m))
|
|
.toList();
|
|
}
|
|
|
|
var dumps = 0;
|
|
Future<void> showTelemetry(String title) async {
|
|
final r = await call('NymeaEnergy.GetLoadTelemetry');
|
|
final p = r['params'] as Map<String, dynamic>;
|
|
if (dumpPrefix != null) {
|
|
final path = '${dumpPrefix}_${++dumps}.json';
|
|
File(path).writeAsStringSync(
|
|
const JsonEncoder.withIndent(' ').convert({'_title': title, 'telemetry': p}));
|
|
print('(charge utile brute écrite : $path)');
|
|
}
|
|
print('\n--- $title (cycle ${p['timestamp'] ?? 'AUCUN'}) ---');
|
|
for (final l in ((p['loads'] as List?) ?? const []).whereType<Map>()) {
|
|
print(' ${l['loadId']} available=${l['available']} '
|
|
'faultCode=${l['faultCode'] ?? '(aucun)'} '
|
|
'alloué=${l['allocatedW']}W → ${(l['decision'] as Map?)?['code']} '
|
|
'${jsonEncode((l['decision'] as Map?)?['params'] ?? {})}');
|
|
}
|
|
}
|
|
|
|
/// Aller-retour neutre : la config relue est-elle celle de départ, clé par clé ?
|
|
bool sameConfig(List<Map<String, dynamic>> a, List<Map<String, dynamic>> b) {
|
|
String canon(List<Map<String, dynamic>> l) {
|
|
final rows = [
|
|
for (final m in l)
|
|
jsonEncode({for (final k in m.keys.toList()..sort()) k: m[k]}),
|
|
]..sort();
|
|
return rows.join('\n');
|
|
}
|
|
|
|
return canon(a) == canon(b);
|
|
}
|
|
|
|
await call('JSONRPC.Hello', {'locale': 'fr_FR'});
|
|
await call('JSONRPC.SetNotificationStatus', {'enabled': true});
|
|
|
|
// ── Restauration seule : on rejoue le FICHIER, pas une relecture filtrée ───
|
|
if (restoreOnly) {
|
|
if (!backupFile.existsSync()) {
|
|
stderr.writeln('Aucune sauvegarde à « $backupPath » : rien à restaurer.');
|
|
stderr.writeln('Restaurer à la main plutôt que de deviner la config d\'origine.');
|
|
await ws.close();
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
final saved = (jsonDecode(backupFile.readAsStringSync())
|
|
as Map<String, dynamic>)['loadConfigs'] as List;
|
|
final original =
|
|
saved.cast<Map>().map((m) => Map<String, dynamic>.from(m)).toList();
|
|
print('Restauration verbatim depuis « $backupPath » '
|
|
'(${original.length} charge(s), lue le '
|
|
'${(jsonDecode(backupFile.readAsStringSync()) as Map)['readAt']}).');
|
|
final r = await call('NymeaEnergy.SetLoadConfig', {'loadConfigs': original});
|
|
print('réponse : ${jsonEncode(r['params'])}');
|
|
await Future<void>.delayed(const Duration(seconds: 3));
|
|
final back = await readConfig();
|
|
print(sameConfig(original, back)
|
|
? 'IDENTIQUE à la sauvegarde — restauration neutre OK.'
|
|
: 'DIVERGENCE après restauration :\n${jsonEncode(back)}');
|
|
await ws.close();
|
|
return;
|
|
}
|
|
|
|
final original = await readConfig();
|
|
print('--- CONFIGURATION LUE (référence de restauration) ---');
|
|
for (final m in original) {
|
|
print(' ${m['id']} ${m['adapter']} priority=${m['priority']} '
|
|
'domain="${m['domain'] ?? ''}"');
|
|
}
|
|
|
|
if (original.any((m) => m['id'] == kProbeLoadId)) {
|
|
print('\nUne charge de sonde traîne déjà sur cette box.');
|
|
print('Relancer avec --restore-only --yes (sauvegarde : $backupPath).');
|
|
await ws.close();
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// La sauvegarde part sur le disque AVANT la première écriture. Un plantage entre les
|
|
// deux laisserait sinon le banc modifié sans référence de retour.
|
|
backupFile.writeAsStringSync(const JsonEncoder.withIndent(' ').convert({
|
|
'_note': 'Configuration de $host lue AVANT fault_probe.dart. À rejouer verbatim '
|
|
'par --restore-only. Ne pas reconstruire : renvoyer ces maps telles quelles.',
|
|
'host': host,
|
|
'readAt': DateTime.now().toIso8601String(),
|
|
'loadConfigs': original,
|
|
}));
|
|
print('\nSauvegarde écrite : $backupPath');
|
|
|
|
// La charge de sonde reprend la forme complète d'une LoadConfig — le plugin sérialise
|
|
// par méta-objet et le validateur de nymea rejette toute clé hors schéma.
|
|
final probe = <String, dynamic>{
|
|
'adapter': 'etmvariableload',
|
|
'domain': '',
|
|
'enabled': true,
|
|
// Pour `etmvariableload`, l'id de la charge EST le ThingId piloté. Celui-ci n'existe
|
|
// pas : c'est ce qui produit THING_MISSING.
|
|
'id': kProbeLoadId,
|
|
'label': kProbeLabel,
|
|
'maxPowerW': 1000,
|
|
'minOffS': 0,
|
|
'minOnS': 0,
|
|
// `dynamic` + `maxPowerW > 0` + `powerLevels` vide : la seule forme d'etmvariableload
|
|
// qui passe `LoadConfig::validate()` sans table de paliers (loadconfig.cpp:286-291).
|
|
// En `fixed`, la box exige des powerLevels commençant à 0 — refus en bloc sinon.
|
|
'mode': 'dynamic',
|
|
'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0},
|
|
'powerLevels': <dynamic>[],
|
|
'priority': original.length + 1,
|
|
'relays': <dynamic>[],
|
|
'sgReady': <String, dynamic>{},
|
|
};
|
|
|
|
print('\n--- AJOUT de la charge de sonde ---');
|
|
final added =
|
|
await call('NymeaEnergy.SetLoadConfig', {'loadConfigs': [...original, probe]});
|
|
print(jsonEncode(added));
|
|
if ((added['params']?['energyError'] as String?) != 'EnergyErrorNoError') {
|
|
// Refus EN BLOC : la configuration n'a pas bougé, la sauvegarde n'a plus d'objet.
|
|
// Le motif du refus n'est PAS dans la réponse RPC — il est au journal de la box
|
|
// (`journalctl -u nymead`), et lui nomme la charge et la règle enfreinte.
|
|
print('Refus de la box — la configuration est inchangée, rien à restaurer.');
|
|
print('Motif : ssh etm@$host \'journalctl -u nymead --since "2 min ago"\' '
|
|
'| grep "SetLoadConfig rejetée"');
|
|
backupFile.deleteSync();
|
|
await ws.close();
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Un cycle d'arbitrage dure une minute : la télémétrie ne reflète le changement
|
|
// qu'au cycle suivant. Conclure avant, c'est reprocher à la box de n'avoir pas
|
|
// encore tourné.
|
|
print('\n… attente de deux cycles d\'arbitrage (130 s)');
|
|
await Future<void>.delayed(const Duration(seconds: 130));
|
|
await showTelemetry('TÉLÉMÉTRIE avec la charge en défaut');
|
|
|
|
print('\n--- ClearLoadFault sur la charge en défaut ---');
|
|
final cleared = await call('NymeaEnergy.ClearLoadFault', {'loadId': kProbeLoadId});
|
|
print('réponse RPC : ${jsonEncode(cleared['params'])}');
|
|
print('→ ce que dit le RPC ne vaut PAS levée. Verdict ci-dessous, sur la télémétrie.');
|
|
|
|
await Future<void>.delayed(const Duration(seconds: 70));
|
|
await showTelemetry('TÉLÉMÉTRIE après ClearLoadFault');
|
|
} finally {
|
|
print('\n--- RESTAURATION verbatim de la configuration d\'origine ---');
|
|
final restored =
|
|
await call('NymeaEnergy.SetLoadConfig', {'loadConfigs': original});
|
|
print(jsonEncode(restored['params']));
|
|
await Future<void>.delayed(const Duration(seconds: 3));
|
|
final back = await readConfig();
|
|
|
|
print('\n--- VERDICT DE RESTAURATION ---');
|
|
if (sameConfig(original, back)) {
|
|
print('IDENTIQUE à la configuration lue au départ, clé par clé — '
|
|
'aller-retour neutre OK.');
|
|
backupFile.deleteSync();
|
|
print('Sauvegarde « $backupPath » retirée : elle n\'a plus d\'objet.');
|
|
} else {
|
|
print('DIVERGENCE — la sauvegarde « $backupPath » est CONSERVÉE.');
|
|
print('Relire, puis rejouer : dart run tools/rpc/fault_probe.dart $host '
|
|
'--restore-only --yes');
|
|
print('config relue :\n${const JsonEncoder.withIndent(' ').convert(back)}');
|
|
}
|
|
await ws.close();
|
|
}
|
|
}
|