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
199 lines
7.2 KiB
Dart
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;
|
|
}
|