etm-powersync-app/tools/rpc/domain_assign.dart
Patrick Schurig 40ea285663 tools(rpc): sondes télémétrie / domaine / défaut, et journal de vérification
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
2026-08-25 19:20:46 +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@192.168.1.75 '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 : '192.168.1.75';
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;
}