FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils », jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter). La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une échéance » est retirée : aucun de ces comportements n'existe dans le moteur. ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel. L'écran le nomme, avec la durée de réarmement. GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant. La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15 n'est exposé par aucune méthode. CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box accepte, et le refus remonte sans en inventer la cause. ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone. Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
272 lines
10 KiB
Dart
272 lines
10 KiB
Dart
/// Lecture du **schéma publié par la box** (`JSONRPC.Introspect`), et rien d'autre.
|
|
///
|
|
/// ## Pourquoi le schéma, et pas une table de versions
|
|
///
|
|
/// Un écran qui grise ses champs à partir d'une liste codée en dur se trompe deux fois :
|
|
/// il ment sur les box plus récentes (champ disponible, resté gris) et sur les plus
|
|
/// anciennes (champ absent, présenté comme actif). Le parc est hétérogène et le restera.
|
|
///
|
|
/// En dérivant le grisage du schéma, un champ que le plugin se met à exposer **s'active
|
|
/// seul**, sur les box à jour, sans nouvelle version de l'app — et reste gris sur les
|
|
/// autres. C'est aussi la seule façon de tenir la promesse inverse : **rien de ce qui
|
|
/// n'est pas au schéma ne sort de l'app**.
|
|
///
|
|
/// ## Ce que la box publie vraiment — relevé sur `.75`, 2026-08-25
|
|
///
|
|
/// `JSONRPC.Introspect` rend quatre sections : `methods`, `notifications`, `types`,
|
|
/// `enums`. Les noms de champs y portent des préfixes qui sont le cœur de ce fichier :
|
|
///
|
|
/// - `o:` — champ **optionnel** ; présent au schéma, il s'écrit.
|
|
/// - `r:` — champ en **lecture seule** dans la charge utile. Il existe, il s'affiche,
|
|
/// mais l'écrire est une erreur. C'est un état DISTINCT de l'absence, et les confondre
|
|
/// afficherait « la box ne l'expose pas encore » sur une mesure parfaitement lisible.
|
|
/// - sans préfixe — requis, et en écriture sur une méthode d'écriture.
|
|
///
|
|
/// ## La version du plugin n'est PAS sur le fil
|
|
///
|
|
/// `JSONRPC.Hello` rend la version de **nymea** (`1.15.2+202606191336~trixie1`) et la
|
|
/// version d'**API des expériences** (`NymeaEnergy 0.8`, `AirConditioning 1.1`). La
|
|
/// version du paquet Debian du moteur — `1.15.2+etm15` — n'est exposée par aucune
|
|
/// méthode : elle ne se lit que par `dpkg` sur la box. Ce qu'un écran peut donc annoncer
|
|
/// honnêtement, c'est la version d'API, qui est de toute façon le bon discriminant : elle
|
|
/// décrit le contrat, pas le paquet qui l'implémente.
|
|
library;
|
|
|
|
/// Ce que le schéma de **cette** box dit d'un champ.
|
|
enum FieldSupport {
|
|
/// Au schéma, et en écriture.
|
|
writable,
|
|
|
|
/// Au schéma, mais `r:` — affichable, jamais écrit.
|
|
readOnly,
|
|
|
|
/// **Pas** au schéma de cette box. C'est le seul cas qui se lit « pas encore exposé ».
|
|
absent,
|
|
|
|
/// Le schéma n'a pas pu être lu. On ne sait pas — et on le dit, plutôt que de
|
|
/// supposer l'un ou l'autre.
|
|
unknown,
|
|
}
|
|
|
|
extension FieldSupportX on FieldSupport {
|
|
/// Vrai seulement si le champ peut partir dans une écriture.
|
|
bool get isWritable => this == FieldSupport.writable;
|
|
|
|
/// Vrai si le champ doit apparaître grisé plutôt qu'actif.
|
|
bool get isGreyed => this != FieldSupport.writable;
|
|
}
|
|
|
|
/// Un nœud du schéma : une map de champs, une liste, ou un scalaire.
|
|
///
|
|
/// Enveloppe volontairement mince — elle ne modélise pas les types, elle répond à une
|
|
/// seule question : *ce champ est-il là, et puis-je l'écrire ?*
|
|
class SchemaNode {
|
|
final Object? _raw;
|
|
final RpcSchema _schema;
|
|
|
|
const SchemaNode(this._raw, this._schema);
|
|
|
|
/// Vrai si ce nœud n'existe pas dans le schéma lu.
|
|
bool get isMissing => _raw == null;
|
|
|
|
/// Descend dans l'élément d'une liste (`[{...}]` → `{...}`).
|
|
SchemaNode get item {
|
|
final r = _raw;
|
|
if (r is List && r.isNotEmpty) return _schema._resolve(r.first);
|
|
return SchemaNode(null, _schema);
|
|
}
|
|
|
|
/// Cherche [name] parmi les clés, en tolérant les préfixes `o:` et `r:`.
|
|
///
|
|
/// \return le support du champ, et le sous-nœud s'il en a un.
|
|
({FieldSupport support, SchemaNode node}) field(String name) {
|
|
final r = _raw;
|
|
if (r is! Map) {
|
|
return (support: FieldSupport.absent, node: SchemaNode(null, _schema));
|
|
}
|
|
for (final entry in r.entries) {
|
|
final key = entry.key.toString();
|
|
final (prefix, bare) = _split(key);
|
|
if (bare != name) continue;
|
|
final support = switch (prefix) {
|
|
'r' => FieldSupport.readOnly,
|
|
_ => FieldSupport.writable, // 'o' (optionnel) et sans préfixe s'écrivent
|
|
};
|
|
return (support: support, node: _schema._resolve(entry.value));
|
|
}
|
|
return (support: FieldSupport.absent, node: SchemaNode(null, _schema));
|
|
}
|
|
|
|
/// Support d'un champ atteint par un chemin (`['loadConfigs', '[]', 'sgReady']`).
|
|
///
|
|
/// `'[]'` descend dans l'élément d'une liste. Un maillon absent rend [FieldSupport.absent]
|
|
/// pour tout le chemin — on ne devine pas ce qu'il y aurait eu dessous.
|
|
FieldSupport supportAt(List<String> path) {
|
|
var node = this;
|
|
var support = FieldSupport.writable;
|
|
for (final step in path) {
|
|
if (step == '[]') {
|
|
node = node.item;
|
|
if (node.isMissing) return FieldSupport.absent;
|
|
continue;
|
|
}
|
|
final r = node.field(step);
|
|
if (r.support == FieldSupport.absent) return FieldSupport.absent;
|
|
// Un `r:` rencontré en chemin contamine la suite : on n'écrit pas dans une
|
|
// sous-structure en lecture seule.
|
|
if (r.support == FieldSupport.readOnly) support = FieldSupport.readOnly;
|
|
node = r.node;
|
|
}
|
|
return support;
|
|
}
|
|
|
|
/// Noms de champs de ce nœud, préfixes retirés.
|
|
List<String> get fieldNames {
|
|
final r = _raw;
|
|
if (r is! Map) return const [];
|
|
return [for (final k in r.keys) _split(k.toString()).$2];
|
|
}
|
|
|
|
static (String?, String) _split(String key) {
|
|
final i = key.indexOf(':');
|
|
if (i <= 0) return (null, key);
|
|
final prefix = key.substring(0, i);
|
|
if (prefix == 'o' || prefix == 'r') return (prefix, key.substring(i + 1));
|
|
return (null, key);
|
|
}
|
|
}
|
|
|
|
/// Le schéma d'une box, tel que `JSONRPC.Introspect` l'a rendu.
|
|
class RpcSchema {
|
|
final Map<String, dynamic> _methods;
|
|
final Map<String, dynamic> _types;
|
|
final Map<String, dynamic> _enums;
|
|
final Map<String, dynamic> _notifications;
|
|
|
|
/// Versions d'API des expériences, lues dans `JSONRPC.Hello` (`{NymeaEnergy: "0.8"}`).
|
|
final Map<String, String> experienceVersions;
|
|
|
|
/// Version de **nymea** (pas celle du plugin — voir l'en-tête de ce fichier).
|
|
final String? nymeaVersion;
|
|
|
|
/// Vrai si l'introspection a réellement abouti. Faux → tout est [FieldSupport.unknown],
|
|
/// et les écrans doivent le dire au lieu de griser ou d'activer au hasard.
|
|
final bool loaded;
|
|
|
|
const RpcSchema._({
|
|
required Map<String, dynamic> methods,
|
|
required Map<String, dynamic> types,
|
|
required Map<String, dynamic> enums,
|
|
required Map<String, dynamic> notifications,
|
|
required this.experienceVersions,
|
|
required this.nymeaVersion,
|
|
required this.loaded,
|
|
}) : _methods = methods,
|
|
_types = types,
|
|
_enums = enums,
|
|
_notifications = notifications;
|
|
|
|
/// Schéma vide — état avant lecture, ou après un échec d'introspection.
|
|
static const RpcSchema unknown = RpcSchema._(
|
|
methods: {},
|
|
types: {},
|
|
enums: {},
|
|
notifications: {},
|
|
experienceVersions: {},
|
|
nymeaVersion: null,
|
|
loaded: false,
|
|
);
|
|
|
|
/// Construit depuis la charge utile de `JSONRPC.Introspect`, et éventuellement
|
|
/// celle de `JSONRPC.Hello` pour les versions d'API.
|
|
factory RpcSchema.fromIntrospect(
|
|
Map<String, dynamic> introspect, {
|
|
Map<String, dynamic>? hello,
|
|
}) {
|
|
Map<String, dynamic> section(String k) {
|
|
final v = introspect[k];
|
|
return v is Map ? Map<String, dynamic>.from(v) : <String, dynamic>{};
|
|
}
|
|
|
|
final versions = <String, String>{};
|
|
final exps = hello?['experiences'];
|
|
if (exps is List) {
|
|
for (final e in exps.whereType<Map>()) {
|
|
final n = e['name'], v = e['version'];
|
|
if (n is String && v is String) versions[n] = v;
|
|
}
|
|
}
|
|
|
|
return RpcSchema._(
|
|
methods: section('methods'),
|
|
types: section('types'),
|
|
enums: section('enums'),
|
|
notifications: section('notifications'),
|
|
experienceVersions: versions,
|
|
nymeaVersion: hello?['version'] as String?,
|
|
loaded: introspect['methods'] is Map,
|
|
);
|
|
}
|
|
|
|
/// Vrai si cette box déclare la méthode. Une méthode absente n'est pas une panne :
|
|
/// c'est une box plus ancienne, et l'écran doit le dire.
|
|
bool hasMethod(String name) => _methods.containsKey(name);
|
|
|
|
/// Vrai si cette box déclare la notification.
|
|
bool hasNotification(String name) => _notifications.containsKey(name);
|
|
|
|
/// Valeurs d'une énumération publiée (`ChargingMode` → `[ChargingModeNormal, …]`).
|
|
///
|
|
/// **Ne jamais coder ces listes en dur** : une box plus récente peut en ajouter, et un
|
|
/// sélecteur figé refuserait une valeur que la box accepte.
|
|
List<String> enumValues(String name) {
|
|
final v = _enums[name];
|
|
return v is List ? v.whereType<String>().toList() : const [];
|
|
}
|
|
|
|
/// Racine des **paramètres** d'une méthode — ce qui part en écriture.
|
|
SchemaNode params(String method) {
|
|
final m = _methods[method];
|
|
if (m is! Map) return SchemaNode(null, this);
|
|
return _resolve(m['params']);
|
|
}
|
|
|
|
/// Racine du **retour** d'une méthode.
|
|
SchemaNode returns(String method) {
|
|
final m = _methods[method];
|
|
if (m is! Map) return SchemaNode(null, this);
|
|
return _resolve(m['returns']);
|
|
}
|
|
|
|
/// Racine d'un type nommé (`ChargingInfo`, `ZoneInfo`, …).
|
|
SchemaNode type(String name) => _resolve(_types[name]);
|
|
|
|
/// Support d'un champ des paramètres d'une méthode.
|
|
///
|
|
/// Rend [FieldSupport.unknown] si le schéma n'a pas été lu — ne jamais confondre
|
|
/// « je ne sais pas » avec « la box ne l'expose pas ».
|
|
FieldSupport paramSupport(String method, List<String> path) {
|
|
if (!loaded) return FieldSupport.unknown;
|
|
if (!hasMethod(method)) return FieldSupport.absent;
|
|
return params(method).supportAt(path);
|
|
}
|
|
|
|
/// Support d'un champ d'un type nommé.
|
|
FieldSupport typeSupport(String typeName, List<String> path) {
|
|
if (!loaded) return FieldSupport.unknown;
|
|
final t = type(typeName);
|
|
if (t.isMissing) return FieldSupport.absent;
|
|
return t.supportAt(path);
|
|
}
|
|
|
|
/// Suit les `$ref:Nom` jusqu'au type concret.
|
|
SchemaNode _resolve(Object? node) {
|
|
var cur = node;
|
|
var hops = 0;
|
|
while (cur is String && cur.startsWith(r'$ref:') && hops++ < 8) {
|
|
cur = _types[cur.substring(5)];
|
|
}
|
|
return SchemaNode(cur, this);
|
|
}
|
|
}
|