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 --yes // dart run tools/rpc/fault_probe.dart --restore-only --yes // (--backup pour changer le fichier de sauvegarde) // (--dump 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 main(List 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 = >>{}; ws.listen((raw) { final m = jsonDecode(raw as String) as Map; final rid = m['id']; if (rid is int && pending.containsKey(rid)) pending.remove(rid)!.complete(m); }); Future> call(String me, [Map p = const {}]) { final i = ++id; final c = Completer>(); pending[i] = c; ws.add(jsonEncode({'id': i, 'method': me, 'params': p})); return c.future.timeout(const Duration(seconds: 20)); } Future>> readConfig() async { final r = await call('NymeaEnergy.GetLoadConfig'); return (r['params']['loadConfigs'] as List) .cast>() .map((m) => Map.from(m)) .toList(); } var dumps = 0; Future showTelemetry(String title) async { final r = await call('NymeaEnergy.GetLoadTelemetry'); final p = r['params'] as Map; 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()) { 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> a, List> b) { String canon(List> 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)['loadConfigs'] as List; final original = saved.cast().map((m) => Map.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.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 = { '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': [], 'priority': original.length + 1, 'relays': [], 'sgReady': {}, }; 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.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.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.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(); } }