import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:etm_powersync_app/models/load_config_entry.dart'; import 'package:etm_powersync_app/services/load_priority.dart'; // §7-4 — affectation d'un domaine, par le CHEMIN D'ÉCRITURE DE L'APP. // // ⚠️ CE SCRIPT ÉCRIT SUR LA BOX. `SetLoadConfig` remplace l'ENSEMBLE des charges. // Le drapeau --yes est obligatoire. // // Il n'invente aucune sérialisation : il importe `LoadConfigEntry.patched()`, // `groupByDomain()` et `flattenToPayload()` — exactement ce que // `LoadConfigProvider.assignDomain()` puis `save()` produisent dans l'app. Une sonde qui // fabriquerait sa propre charge utile ne prouverait rien sur l'app. // // Le `domain` est une PURE MÉTADONNÉE : l'arbitre ne la lit pas, aucun comportement // moteur n'en dépend. L'écriture reste réelle et déclenche une réévaluation des // adaptateurs — d'où le --yes, et d'où l'intérêt de lire le journal en parallèle : // // ssh etm@192.168.1.75 'journalctl -u nymead -f' // // Usage : // dart run tools/rpc/domain_assign.dart --set chauffe-eau=ecs --set pac-terrain=hvac --yes // dart run tools/rpc/domain_assign.dart --clear --yes # tout déclasser Future main(List args) async { final assignments = {}; for (var i = 0; i < args.length; i++) { if (args[i] == '--set' && i + 1 < args.length) { final parts = args[i + 1].split('='); if (parts.length != 2) { stderr.writeln('--set attend loadId=domaine'); exitCode = 2; return; } assignments[parts[0]] = parts[1]; } } final clearAll = args.contains('--clear'); if (assignments.isEmpty && !clearAll) { stderr.writeln('Rien à faire : passer --set loadId=domaine, ou --clear.'); exitCode = 2; return; } for (final d in assignments.values) { if (!kAssignableDomains.contains(d)) { // L'énumération est fermée côté plugin et `SetLoadConfig` rejette EN BLOC : // envoyer un domaine inventé ferait échouer l'enregistrement de TOUTES les // charges, pas seulement de celle qu'on classe. stderr.writeln('Domaine « $d » hors énumération : $kAssignableDomains'); exitCode = 2; return; } } if (!args.contains('--yes')) { stderr.writeln('Ce script ÉCRIT sur la box (NymeaEnergy.SetLoadConfig).'); stderr.writeln('Relancer avec --yes pour confirmer.'); exitCode = 2; return; } final host = args.isNotEmpty && !args.first.startsWith('--') ? args.first : '192.168.1.75'; final ws = await WebSocket.connect('ws://$host:4444') .timeout(const Duration(seconds: 8)); var id = 0; final pending = >>{}; final notifs = []; 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); } else if (m['notification'] != null) { notifs.add('${DateTime.now().toIso8601String()} ${m['notification']}'); } }); 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)); } await call('JSONRPC.Hello', {'locale': 'fr_FR'}); await call('JSONRPC.SetNotificationStatus', {'enabled': true}); final before = await call('NymeaEnergy.GetLoadConfig'); final rawBefore = (before['params']['loadConfigs'] as List) .cast>() .map((m) => Map.from(m)) .toList(); print('--- AVANT ---'); for (final m in rawBefore) { print(' ${m['id']} priority=${m['priority']} domain="${m['domain'] ?? ''}"'); } // ── Le chemin de l'app, sans l'app ──────────────────────────────────────── var groups = groupByDomain(rawBefore.map(LoadConfigEntry.new).toList()).groups; for (final e in { if (clearAll) for (final m in rawBefore) m['id'] as String: kUnclassifiedDomain, ...assignments, }.entries) { groups = _assign(groups, e.key, e.value); } final payload = flattenToPayload(groups); print('\n--- CHARGE UTILE ENVOYÉE (produite par le code de l\'app) ---'); for (final m in payload) { print(' ${m['id']} priority=${m['priority']} domain="${m['domain'] ?? ''}"'); } final conflict = validateSet([for (final g in groups) ...g.loads]); if (conflict != null) { print('\nMiroir de validateSet() : conflit ${conflict.kind} — rien n\'est envoyé.'); await ws.close(); exitCode = 1; return; } final set = await call('NymeaEnergy.SetLoadConfig', {'loadConfigs': payload}); print('\n--- réponse SetLoadConfig ---'); print(jsonEncode(set)); await Future.delayed(const Duration(seconds: 3)); final after = await call('NymeaEnergy.GetLoadConfig'); final rawAfter = (after['params']['loadConfigs'] as List) .cast>() .map((m) => Map.from(m)) .toList(); print('\n--- APRÈS (relu sur la box) ---'); for (final m in rawAfter) { print(' ${m['id']} priority=${m['priority']} domain="${m['domain'] ?? ''}"'); } print('\n--- notifications reçues ---'); print(notifs.isEmpty ? '(aucune)' : notifs.join('\n')); // ── Verdict : seuls domain et priority ont bougé ────────────────────────── print('\n--- VERDICT ---'); var intact = true; for (final origin in rawBefore) { final now = rawAfter.firstWhere((m) => m['id'] == origin['id'], orElse: () => {}); if (now.isEmpty) { print('PERDUE : ${origin['id']}'); intact = false; continue; } for (final k in origin.keys) { if (k == 'domain' || k == 'priority') continue; if (jsonEncode(now[k]) != jsonEncode(origin[k])) { print('ALTÉRÉE : ${origin['id']}.$k ${jsonEncode(origin[k])} ' '→ ${jsonEncode(now[k])}'); intact = false; } } } print(intact ? 'Charges utiles intactes — seuls domain/priority ont changé.' : 'Divergence : voir ci-dessus.'); await ws.close(); } /// Miroir de `LoadConfigProvider.assignDomain()` — la charge quitte son groupe et /// rejoint la fin du groupe cible. List _assign(List groups, String loadId, String domain) { final current = [for (final g in groups) g.copyWith(loads: [...g.loads])]; LoadConfigEntry? moved; for (final g in current) { final i = g.loads.indexWhere((e) => e.id == loadId); if (i >= 0) { moved = g.loads.removeAt(i); break; } } if (moved == null) { stderr.writeln('Charge « $loadId » absente de la configuration — ignorée.'); return groups; } final patched = LoadConfigEntry(moved.patched(domain: domain)); final gi = current.indexWhere((g) => g.domain == domain); if (gi >= 0) { current[gi] = current[gi].copyWith(loads: [...current[gi].loads, patched]); } else { current.add(DomainGroup(domain: domain, loads: [patched])); } current.removeWhere((g) => g.loads.isEmpty); return current; }