/// 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 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 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 _methods; final Map _types; final Map _enums; final Map _notifications; /// Versions d'API des expériences, lues dans `JSONRPC.Hello` (`{NymeaEnergy: "0.8"}`). final Map 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 methods, required Map types, required Map enums, required Map 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 introspect, { Map? hello, }) { Map section(String k) { final v = introspect[k]; return v is Map ? Map.from(v) : {}; } final versions = {}; final exps = hello?['experiences']; if (exps is List) { for (final e in exps.whereType()) { 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 enumValues(String name) { final v = _enums[name]; return v is List ? v.whereType().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 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 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); } }