#!/usr/bin/env python3 """ gen_api_reference.py — génère la référence API JsonRPC depuis introspect.json. Lit le dump d'introspection nymea (introspect.json) et produit : - Une page par namespace dans docs/api/metier/ ou docs/api/systeme/ - docs/api/notifications.md - docs/api/types.md - docs/api/index.md (page d'accueil statique — créée si absente, jamais écrasée) - docs/api/SUMMARY.md (nav literate-nav) Marqueurs gérés : Usage : python3 scripts/gen_api_reference.py --src introspect.json --docs docs python3 scripts/gen_api_reference.py --src introspect.json --docs docs --check """ from __future__ import annotations import argparse import json import re import sys from collections import defaultdict from pathlib import Path try: pass # stdlib only except ImportError: pass # ── Namespaces ──────────────────────────────────────────────────────────────── NS_METIER_ORDER = [ "JSONRPC", "Energy", "Integrations", "AirConditioning", "EvDash", "Rules", "Scripts", "Tags", "Logging", "AppData", ] NS_SYSTEM_ORDER = [ "Configuration", "System", "Users", "NetworkManager", "ModbusRtu", "Debug", "Transfers", "ZWave", "Zigbee", ] NS_METIER = set(NS_METIER_ORDER) NS_SYSTEM = set(NS_SYSTEM_ORDER) # ── Badges permission scope ─────────────────────────────────────────────────── PERM_BADGES: dict[str, str] = { "PermissionScopeNone": 'PUBLIC', "PermissionScopeControlThings": 'CONTROL', "PermissionScopeExecuteRules": 'EXECUTE', "PermissionScopeConfigureThings": 'CONFIGURE', "PermissionScopeConfigureRules": 'CONFIGURE-RULES', "PermissionScopeAdmin": 'ADMIN', } MARKER_RE = re.compile( r"()(?P.*?)()", re.DOTALL, ) # ── Helpers ─────────────────────────────────────────────────────────────────── def ns_slug(ns: str) -> str: return ns.lower() def ns_folder(ns: str) -> str: return "metier" if ns in NS_METIER else "systeme" def parse_field_name(raw: str) -> tuple[str, bool, bool]: """Strip `o:` / `r:` prefixes. Returns (name, optional, readonly).""" name, optional, readonly = raw, False, False while True: if name.startswith("o:"): optional = True name = name[2:] elif name.startswith("r:"): readonly = True name = name[2:] else: break return name, optional, readonly def render_type(value: object, types_path: str = "../types.md") -> str: """Render a type value as Markdown, resolving $ref to a clickable link.""" if isinstance(value, list): inner = render_type(value[0], types_path) if value else "`?`" return f"{inner}[]" if isinstance(value, str): if value.startswith("$ref:"): type_name = value[5:] anchor = type_name.lower() return f"[{type_name}]({types_path}#{anchor})" return f"`{value}`" return f"`{value!s}`" def render_type_self(value: object) -> str: """Like render_type but for self-referencing anchors (types page).""" if isinstance(value, list): inner = render_type_self(value[0]) if value else "`?`" return f"{inner}[]" if isinstance(value, str): if value.startswith("$ref:"): type_name = value[5:] anchor = type_name.lower() return f"[{type_name}](#{anchor})" return f"`{value}`" return f"`{value!s}`" def render_fields_table(fields: dict, types_path: str = "../types.md") -> str: """Render a params/returns dict as a Markdown table. Returns trailing newline.""" if not fields: return "_Aucun paramètre._\n" rows = [] for raw_name in sorted(fields): value = fields[raw_name] name, optional, readonly = parse_field_name(raw_name) type_str = render_type(value, types_path) notes = [] if optional: notes.append("optionnel") if readonly: notes.append("lecture seule") rows.append([f"`{name}`", type_str, " · ".join(notes)]) lines = [ "| Champ | Type | Notes |", "| --- | --- | --- |", ] for r in rows: lines.append("| " + " | ".join(r) + " |") return "\n".join(lines) + "\n" # ── Rendu d'une méthode ─────────────────────────────────────────────────────── def render_method(name: str, method: dict, types_path: str) -> str: perm = method.get("permissionScope", "") badge = PERM_BADGES.get(perm) if not badge: badge = f'{perm or "inconnu"}' print(f" INFO : permissionScope inconnu '{perm}' pour {name}", file=sys.stderr) desc = method.get("description", "").strip() params = method.get("params") or {} returns = method.get("returns") or {} parts: list[str] = [] parts.append(f"### {name}") parts.append("") parts.append(badge) parts.append("") if desc: parts.append(desc) parts.append("") parts.append("**Paramètres :**") parts.append("") parts.append(render_fields_table(params, types_path).rstrip()) parts.append("") parts.append("**Retour :**") parts.append("") parts.append(render_fields_table(returns, types_path).rstrip()) parts.append("") parts.append("---") parts.append("") return "\n".join(parts) # ── Contenu des blocs générés ───────────────────────────────────────────────── def render_namespace_content( ns: str, methods: dict, notifications: dict, types_path: str, ) -> str: """Contenu du bloc BEGIN/END d'une page namespace.""" ns_methods = {k: v for k, v in methods.items() if k.startswith(ns + ".")} ns_notifs = {k: v for k, v in notifications.items() if k.startswith(ns + ".")} parts: list[str] = [] if ns_methods: parts.append("## Méthodes") parts.append("") for name in sorted(ns_methods): parts.append(render_method(name, ns_methods[name], types_path)) if ns_notifs: parts.append("## Notifications") parts.append("") for name in sorted(ns_notifs): notif = ns_notifs[name] desc = notif.get("description", "").strip() notif_params = notif.get("params") or {} parts.append(f"### {name}") parts.append("") if desc: parts.append(desc) parts.append("") parts.append("**Paramètres :**") parts.append("") parts.append(render_fields_table(notif_params, types_path).rstrip()) parts.append("") parts.append("---") parts.append("") return "\n".join(parts).rstrip() + "\n" def render_notifications_content(notifications: dict, types_path: str = "types.md") -> str: """Contenu du bloc BEGIN/END de la page Notifications.""" by_ns: dict[str, dict] = defaultdict(dict) for k, v in notifications.items(): ns = k.split(".")[0] by_ns[ns][k] = v all_ns_sorted = sorted( by_ns.keys(), key=lambda x: (0 if x in NS_METIER else 1, x), ) parts: list[str] = [] for ns in all_ns_sorted: parts.append(f"## {ns}") parts.append("") for name in sorted(by_ns[ns]): notif = by_ns[ns][name] desc = notif.get("description", "").strip() notif_params = notif.get("params") or {} parts.append(f"### {name}") parts.append("") if desc: parts.append(desc) parts.append("") parts.append("**Paramètres :**") parts.append("") parts.append(render_fields_table(notif_params, types_path).rstrip()) parts.append("") parts.append("---") parts.append("") return "\n".join(parts).rstrip() + "\n" def render_types_content(types: dict, enums: dict, flags: dict) -> str: """Contenu du bloc BEGIN/END de la page Types & Enums.""" parts: list[str] = [] # Types (objets et listes) parts.append("## Types") parts.append("") for name in sorted(types): anchor = name.lower() defn = types[name] parts.append(f'### {name} {{#{anchor}}}') parts.append("") if isinstance(defn, list): inner = render_type_self(defn[0]) if defn else "`?`" parts.append(f"Liste de {inner}.") parts.append("") elif isinstance(defn, dict): rows = [] for raw_name in sorted(defn): value = defn[raw_name] field_name, optional, readonly = parse_field_name(raw_name) type_str = render_type_self(value) notes = [] if optional: notes.append("optionnel") if readonly: notes.append("lecture seule") rows.append([f"`{field_name}`", type_str, " · ".join(notes)]) lines = ["| Champ | Type | Notes |", "| --- | --- | --- |"] for r in rows: lines.append("| " + " | ".join(r) + " |") parts.append("\n".join(lines)) parts.append("") else: parts.append(f"`{defn}`") parts.append("") parts.append("---") parts.append("") # Enums parts.append("## Enums") parts.append("") for name in sorted(enums): anchor = name.lower() values = enums[name] parts.append(f'### {name} {{#{anchor}}}') parts.append("") if isinstance(values, list): for v in values: if isinstance(v, str) and v.startswith("$ref:"): parts.append(f"- {render_type_self(v)}") else: parts.append(f"- `{v}`") parts.append("") parts.append("---") parts.append("") # Flags if flags: parts.append("## Flags") parts.append("") for name in sorted(flags): anchor = name.lower() values = flags[name] parts.append(f'### {name} {{#{anchor}}}') parts.append("") if isinstance(values, list): for v in values: if isinstance(v, str) and v.startswith("$ref:"): parts.append(f"- {render_type_self(v)}") else: parts.append(f"- `{v}`") parts.append("") parts.append("---") parts.append("") return "\n".join(parts).rstrip() + "\n" # ── Construction des pages complètes ───────────────────────────────────────── def build_namespace_page(ns: str, methods: dict, notifications: dict) -> str: """Page namespace complète (pour ensure_pages — doit correspondre à process()).""" gen = render_namespace_content(ns, methods, notifications, types_path="../types.md") block = f"\n{gen}\n" return f"# {ns}\n\n{block}\n" def build_notifications_page(notifications: dict) -> str: gen = render_notifications_content(notifications, types_path="types.md") block = f"\n{gen}\n" return f"# Notifications\n\n{block}\n" def build_types_page(types: dict, enums: dict, flags: dict) -> str: gen = render_types_content(types, enums, flags) block = f"\n{gen}\n" return f"---\nhide:\n - toc\n---\n\n# Types & Enums\n\n{block}\n" INDEX_PAGE_CONTENT = """\ --- hide: - toc --- # Référence API JsonRPC ETM PowerSync expose l'**API JSON-RPC de nymea** en local, y compris les extensions ETM (`Energy`, `AirConditioning`, `EvDash`). ## Connexion | Mode | Adresse | Notes | | --- | --- | --- | | **TCP brut** | `:2222` | nymea parle en premier (welcome JSON `\\n`-delimited) | | **WebSocket** | `ws://:4444` | le client envoie `JSONRPC.Hello` en premier | ## Authentification 1. Appeler [`JSONRPC.Authenticate`](metier/jsonrpc.md#jsonrpcauthenticate) avec `username`, `password` et `deviceName` → reçoit un `token`. 2. Inclure le `token` dans chaque requête suivante. 3. Activer les push : `JSONRPC.SetNotificationStatus` `{ "enabled": true }`. ## Conventions énergie | Grandeur | Convention | | --- | --- | | Production PV | **valeur négative** (convention producteur) | | `currentPowerAcquisition` | réseau — positif = import, 0 si export | | `currentPowerStorage` | batterie — positif = charge, négatif = décharge | | Totaux d'énergie | en **kWh** | | Timestamps | en **secondes** (Unix epoch) | ## Badges de permission | Badge | Scope | Description | | --- | --- | --- | | PUBLIC | `PermissionScopeNone` | Lecture publique, aucun token requis | | CONTROL | `PermissionScopeControlThings` / `ExecuteRules` | Contrôle des appareils et règles | | CONFIGURE | `PermissionScopeConfigureThings` / `ConfigureRules` | Configuration | | ADMIN | `PermissionScopeAdmin` | Administration système | """ # ── SUMMARY.md (literate-nav) ───────────────────────────────────────────────── def generate_summary(all_ns: list[str], docs: Path, check: bool) -> int: """Génère docs/api/SUMMARY.md pour literate-nav. Retourne 1 si périmé en --check.""" metier_ns = [ns for ns in NS_METIER_ORDER if ns in set(all_ns)] system_ns = [ns for ns in NS_SYSTEM_ORDER if ns in set(all_ns)] unknown_ns = sorted(ns for ns in all_ns if ns not in NS_METIER and ns not in NS_SYSTEM) if unknown_ns: for ns in unknown_ns: print(f" INFO : namespace '{ns}' non classé → groupe systeme par défaut", file=sys.stderr) lines = ["* [Référence API](index.md)"] if metier_ns: lines.append("* API — Métier") for ns in metier_ns: lines.append(f" * [{ns}](metier/{ns_slug(ns)}.md)") if system_ns or unknown_ns: lines.append("* API — Système") for ns in system_ns + unknown_ns: lines.append(f" * [{ns}](systeme/{ns_slug(ns)}.md)") lines.append("* [Notifications](notifications.md)") lines.append("* [Types & Enums](types.md)") new_content = "\n".join(lines) + "\n" path = docs / "api" / "SUMMARY.md" if check: if not path.exists() or path.read_text(encoding="utf-8") != new_content: print(f" SUMMARY.md périmé : {path.relative_to(docs.parent)}") return 1 return 0 path.parent.mkdir(parents=True, exist_ok=True) path.write_text(new_content, encoding="utf-8") print(f"SUMMARY.md → {path.relative_to(docs.parent)}") return 0 # ── Création des fichiers manquants ─────────────────────────────────────────── def ensure_pages(data: dict, docs: Path, check: bool) -> int: """Crée les pages API inexistantes. Retourne 1 si manquant en mode --check.""" methods = data["methods"] notifications = data["notifications"] types_d = data["types"] enums = data["enums"] flags = data["flags"] all_ns = data["_all_ns"] rc = 0 # Pages namespace for ns in all_ns: folder = ns_folder(ns) path = docs / "api" / folder / f"{ns_slug(ns)}.md" if path.exists(): continue content = build_namespace_page(ns, methods, notifications) if check: print(f" MANQUANT : {path.relative_to(docs.parent)}") rc = 1 else: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") print(f" CRÉÉ : {path.relative_to(docs.parent)}") # Notifications notif_path = docs / "api" / "notifications.md" if not notif_path.exists(): content = build_notifications_page(notifications) if check: print(f" MANQUANT : {notif_path.relative_to(docs.parent)}") rc = 1 else: notif_path.parent.mkdir(parents=True, exist_ok=True) notif_path.write_text(content, encoding="utf-8") print(f" CRÉÉ : {notif_path.relative_to(docs.parent)}") # Types types_path = docs / "api" / "types.md" if not types_path.exists(): content = build_types_page(types_d, enums, flags) if check: print(f" MANQUANT : {types_path.relative_to(docs.parent)}") rc = 1 else: types_path.parent.mkdir(parents=True, exist_ok=True) types_path.write_text(content, encoding="utf-8") print(f" CRÉÉ : {types_path.relative_to(docs.parent)}") # Index (statique — créé une fois, jamais écrasé) index_path = docs / "api" / "index.md" if not index_path.exists(): if check: print(f" MANQUANT : {index_path.relative_to(docs.parent)}") rc = 1 else: index_path.parent.mkdir(parents=True, exist_ok=True) index_path.write_text(INDEX_PAGE_CONTENT, encoding="utf-8") print(f" CRÉÉ : {index_path.relative_to(docs.parent)}") return rc # ── Injection dans les marqueurs ────────────────────────────────────────────── def process(docs: Path, data: dict, check: bool) -> int: """Injecte le contenu généré dans les marqueurs BEGIN/END des pages API.""" methods = data["methods"] notifications = data["notifications"] types_d = data["types"] enums = data["enums"] flags = data["flags"] all_ns_set = set(data["_all_ns"]) changed: list[Path] = [] api_dir = docs / "api" for md in sorted(api_dir.rglob("*.md")): if md.name == "SUMMARY.md": continue text = md.read_text(encoding="utf-8") if "