etm-powersync-app/tools/rpc/domain_assign.dart
Patrick Schurig bedeabf767 fix(banc): l'adresse du banc passe à 10.42.60.21 — sondes, tests on-device, consignes
Le banc `hems` est renuméroté (192.168.1.75 → 10.42.60.21). Portés : les 11
sondes `tools/rpc/*` et leur README, les 3 tests on-device, le `hint` de
l'écran Installations, les procédures de CLAUDE.md et des deux documents de
cadrage.

Ce qui n'est PAS touché, et c'est délibéré : les relevés datés (RELEVE_LOTC,
RECAP, Prompt 3g2, maquettes) et les fixtures `hems75_*.json`. Un relevé daté
dit ce qui a été observé ce jour-là ; le réécrire échange une trace contre une
cohérence de surface. CLAUDE.md porte la note qui fait le lien : `.75` et
`hems75` désignent la même machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-13 06:37:33 +02:00

199 lines
7.2 KiB
Dart

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@10.42.60.21 'journalctl -u nymead -f'
//
// Usage :
// dart run tools/rpc/domain_assign.dart <host> --set chauffe-eau=ecs --set pac-terrain=hvac --yes
// dart run tools/rpc/domain_assign.dart <host> --clear --yes # tout déclasser
Future<void> main(List<String> args) async {
final assignments = <String, String>{};
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 : '10.42.60.21';
final ws = await WebSocket.connect('ws://$host:4444')
.timeout(const Duration(seconds: 8));
var id = 0;
final pending = <int, Completer<Map<String, dynamic>>>{};
final notifs = <String>[];
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);
} else if (m['notification'] != null) {
notifs.add('${DateTime.now().toIso8601String()} ${m['notification']}');
}
});
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));
}
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<String, dynamic>>()
.map((m) => Map<String, dynamic>.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<void>.delayed(const Duration(seconds: 3));
final after = await call('NymeaEnergy.GetLoadConfig');
final rawAfter = (after['params']['loadConfigs'] as List)
.cast<Map<String, dynamic>>()
.map((m) => Map<String, dynamic>.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: () => <String, dynamic>{});
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<DomainGroup> _assign(List<DomainGroup> 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;
}