diff --git a/scripts/gen_api_reference.py b/scripts/gen_api_reference.py index 465861d..d3c03e3 100644 --- a/scripts/gen_api_reference.py +++ b/scripts/gen_api_reference.py @@ -121,10 +121,37 @@ def render_type_self(value: object) -> str: return f"`{value!s}`" +# ═════════════════════════════════════════════════════════════════════════════ +# VOCABULAIRE DU BLOC DE RÉFÉRENCE — EN ANGLAIS, UNE SEULE FOIS +# +# Décision du 2026-10-12 (multi-langue FR/DE/EN) : le contenu entre les marqueurs +# BEGIN/END GENERATED est servi IDENTIQUE dans les trois langues, en anglais. +# Ici la raison est encore plus directe que pour les fiches appareils : les noms +# de méthodes, de champs, de types et les `description` viennent d'introspect.json, +# c'est-à-dire de nymea, et sont déjà en anglais. Traduire les seuls en-têtes de +# colonnes aurait produit des tableaux bilingues. +# +# La PROSE des pages API (api/index.md, integrations/jsonrpc-api.md) est, elle, +# traduite dans `*.de.md` / `*.en.md`. +# ═════════════════════════════════════════════════════════════════════════════ + +REF = { + "no_params": "_No parameters._\n", + "field_cols": ["Field", "Type", "Notes"], + "optional": "optional", + "readonly": "read-only", + "params_lbl": "**Parameters:**", + "returns_lbl": "**Returns:**", + "methods_h": "## Methods", + "notifs_h": "## Notifications", + "unknown": "unknown", +} + + 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" + return REF["no_params"] rows = [] for raw_name in sorted(fields): value = fields[raw_name] @@ -132,13 +159,13 @@ def render_fields_table(fields: dict, types_path: str = "../types.md") -> str: type_str = render_type(value, types_path) notes = [] if optional: - notes.append("optionnel") + notes.append(REF["optional"]) if readonly: - notes.append("lecture seule") + notes.append(REF["readonly"]) rows.append([f"`{name}`", type_str, " · ".join(notes)]) lines = [ - "| Champ | Type | Notes |", - "| --- | --- | --- |", + "| " + " | ".join(REF["field_cols"]) + " |", + "| " + " | ".join("---" for _ in REF["field_cols"]) + " |", ] for r in rows: lines.append("| " + " | ".join(r) + " |") @@ -151,7 +178,7 @@ 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"}' + badge = f'{perm or REF["unknown"]}' print(f" INFO : permissionScope inconnu '{perm}' pour {name}", file=sys.stderr) desc = method.get("description", "").strip() params = method.get("params") or {} @@ -165,11 +192,11 @@ def render_method(name: str, method: dict, types_path: str) -> str: if desc: parts.append(desc) parts.append("") - parts.append("**Paramètres :**") + parts.append(REF["params_lbl"]) parts.append("") parts.append(render_fields_table(params, types_path).rstrip()) parts.append("") - parts.append("**Retour :**") + parts.append(REF["returns_lbl"]) parts.append("") parts.append(render_fields_table(returns, types_path).rstrip()) parts.append("") @@ -193,7 +220,7 @@ def render_namespace_content( parts: list[str] = [] if ns_methods: - parts.append("## Méthodes") + parts.append(REF["methods_h"]) parts.append("") for name in sorted(ns_methods): parts.append(render_method(name, ns_methods[name], types_path)) @@ -210,7 +237,7 @@ def render_namespace_content( if desc: parts.append(desc) parts.append("") - parts.append("**Paramètres :**") + parts.append(REF["params_lbl"]) parts.append("") parts.append(render_fields_table(notif_params, types_path).rstrip()) parts.append("") @@ -245,7 +272,7 @@ def render_notifications_content(notifications: dict, types_path: str = "types.m if desc: parts.append(desc) parts.append("") - parts.append("**Paramètres :**") + parts.append(REF["params_lbl"]) parts.append("") parts.append(render_fields_table(notif_params, types_path).rstrip()) parts.append("") @@ -279,11 +306,12 @@ def render_types_content(types: dict, enums: dict, flags: dict) -> str: type_str = render_type_self(value) notes = [] if optional: - notes.append("optionnel") + notes.append(REF["optional"]) if readonly: - notes.append("lecture seule") + notes.append(REF["readonly"]) rows.append([f"`{field_name}`", type_str, " · ".join(notes)]) - lines = ["| Champ | Type | Notes |", "| --- | --- | --- |"] + lines = ["| " + " | ".join(REF["field_cols"]) + " |", + "| " + " | ".join("---" for _ in REF["field_cols"]) + " |"] for r in rows: lines.append("| " + " | ".join(r) + " |") parts.append("\n".join(lines)) diff --git a/scripts/gen_device_reference.py b/scripts/gen_device_reference.py index e77f463..16404a0 100644 --- a/scripts/gen_device_reference.py +++ b/scripts/gen_device_reference.py @@ -29,20 +29,56 @@ try: except ImportError: sys.exit("PyYAML est requis : pip install PyYAML") +# ═════════════════════════════════════════════════════════════════════════════ +# VOCABULAIRE DU BLOC DE RÉFÉRENCE — EN ANGLAIS, UNE SEULE FOIS +# +# Décision du 2026-10-12 (multi-langue FR/DE/EN) : le contenu entre les +# marqueurs BEGIN/END GENERATED est servi IDENTIQUE dans les trois langues, en +# anglais. Raison : son contenu vient de l'introspection nymea, et nymea ne +# sert AUCUNE traduction sur notre parc — vérifié le 2026-10-12 sur le banc +# `hems`, où GetThingClasses rend la MÊME charge utile (sha256 identique, +# 243 340 octets) pour fr_FR, de_DE et en_US. Les noms de modèles, de registres +# et d'unités sont donc déjà en anglais ; traduire seulement les en-têtes de +# colonnes autour aurait produit des pages bilingues sans rien gagner. +# +# Ce qui est traduit, lui, c'est la NAVIGATION et la PROSE — voir NAV_LABELS +# plus bas et les fichiers `*.de.md` / `*.en.md`. +# ═════════════════════════════════════════════════════════════════════════════ + INTERFACE_ROLE = { - "energymeter": "Compteur d'énergie", - "smartmeterconsumer": "Compteur de consommation", - "smartmeterproducer": "Compteur de production", - "evcharger": "Borne de recharge", - "heatpump": "Pompe à chaleur", - "smartmeter": "Compteur intelligent", + "energymeter": "Energy meter", + "smartmeterconsumer": "Consumption meter", + "smartmeterproducer": "Production meter", + "evcharger": "EV charger", + "heatpump": "Heat pump", + "smartmeter": "Smart meter", } TECH_INTERFACES = {"connectable", "networkdevice", "wirelessconnectable"} CREATE_METHOD = { - "discovery": "Découverte automatique", - "user": "Ajout manuel", - "auto": "Automatique", + "discovery": "Auto-discovery", + "user": "Manual", + "auto": "Automatic", +} + +# Libellés du bloc de référence. Un seul jeu, anglais (cf. en-tête ci-dessus). +REF = { + "manufacturer": "**Manufacturer:**", + "plugin": "**Plugin:**", + "models_h": "#### Supported models", + "models_cols": ["Model", "Role", "Transport", "Pairing", "Quantities"], + "detail_h": "#### Per-model detail", + "discovery_lbl": "_Discovery parameters:_", + "discovery_cols": ["Key", "Label", "Type", "Range", "Default"], + "settings_lbl": "_Settings:_", + "settings_cols": ["Key", "Label", "Type", "Range", "Default", "Read-only"], + "states_lbl": "_Measured quantities:_", + "states_cols": ["Key", "Quantity", "Type", "Unit"], + "yes": "yes", + "no": "no", + "cat_cols": ["Device", "Protocol", "Channel", "Origin", "Stability"], + "cat_empty": "_No device in this category._\n", + "matrix_cols": ["Brand / Model", "Protocol", "Channel", "Origin", "Stability"], } CATEGORY_LABELS = { @@ -56,6 +92,44 @@ CATEGORY_LABELS = { "generic": "Types génériques", } +# ═════════════════════════════════════════════════════════════════════════════ +# PROSE PAR LANGUE — ce qui entoure le bloc de référence +# +# Le bloc de référence est en anglais une seule fois (REF ci-dessus) ; ce qui +# l'entoure — titre de catégorie, intertitre « Référence », mention de guide à +# venir — est de la PROSE et se traduit. Sans ça, chaque nouvel appareil ajouté +# réintroduirait du français dans /de/ et /en/ par le simple fait d'être créé. +# ═════════════════════════════════════════════════════════════════════════════ + +LOCALES = ["fr", "de", "en"] # fr = langue par défaut (fichier sans suffixe) +DEFAULT_LOCALE = "fr" + +SCAFFOLD = { + "fr": {"ref_h": "## Référence", "guide_todo": "_Guide d'installation à venir._"}, + "de": {"ref_h": "## Referenz", "guide_todo": "_Installationsanleitung folgt._"}, + "en": {"ref_h": "## Reference", "guide_todo": "_Installation guide coming soon._"}, +} + +CATEGORY_LABELS_I18N = { + "fr": CATEGORY_LABELS, + "de": { + "compteur": "Zähler", "irve": "Wallboxen", "smartdevice": "SmartDevices", + "hvac": "HVAC", "onduleur": "Wechselrichter / PV", "batterie": "Speicher / ESS", + "tarif": "Tarife & Prognosen", "generic": "Generische Typen", + }, + "en": { + "compteur": "Meters", "irve": "EV chargers", "smartdevice": "SmartDevices", + "hvac": "HVAC", "onduleur": "Inverters / PV", "batterie": "Batteries / ESS", + "tarif": "Tariffs & forecasts", "generic": "Generic types", + }, +} + + +def loc_name(stem: str, locale: str) -> str: + """Nom de fichier mkdocs-static-i18n en mode `suffix` : `x.md`, `x.de.md`, `x.en.md`.""" + return f"{stem}.md" if locale == DEFAULT_LOCALE else f"{stem}.{locale}.md" + + CATEGORY_FOLDER = { "compteur": "compteurs", "irve": "bornes", @@ -248,18 +322,18 @@ def render_params(tc: dict) -> list: if disc: rows = [[f"`{p['name']}`", p.get("displayName", ""), p.get("type", ""), fmt_range(p), fmt_default(p)] for p in disc] - lines.append("_Paramètres de découverte :_") + lines.append(REF["discovery_lbl"]) lines.append("") - lines.append(md_table(["Clé", "Libellé", "Type", "Plage", "Défaut"], rows)) + lines.append(md_table(REF["discovery_cols"], rows)) lines.append("") settings = tc.get("paramTypes", []) if settings: rows = [[f"`{p['name']}`", p.get("displayName", ""), p.get("type", ""), fmt_range(p), fmt_default(p), - "oui" if p.get("readOnly") else "non"] for p in settings] - lines.append("_Réglages :_") + REF["yes"] if p.get("readOnly") else REF["no"]] for p in settings] + lines.append(REF["settings_lbl"]) lines.append("") - lines.append(md_table(["Clé", "Libellé", "Type", "Plage", "Défaut", "Lecture seule"], rows)) + lines.append(md_table(REF["settings_cols"], rows)) lines.append("") return lines @@ -270,15 +344,15 @@ def render_states(tc: dict) -> list: return [] rows = [[f"`{s['name']}`", s.get("displayName", ""), s.get("type", ""), s.get("unit") or "—"] for s in states] - return [md_table(["Clé", "Grandeur", "Type", "Unité"], rows), ""] + return [md_table(REF["states_cols"], rows), ""] def render_plugin(plugin: dict) -> str: out = [] vendors = plugin.get("vendors", []) vname = ", ".join(v.get("displayName", v.get("name", "")) for v in vendors) - out.append(f"**Fabricant :** {vname} ") - out.append(f"**Plugin :** `{plugin.get('name', '')}`") + out.append(f'{REF["manufacturer"]} {vname} ') + out.append(f'{REF["plugin"]} `{plugin.get("name", "")}`') out.append("") tcs = [(v, tc) for v in vendors for tc in v.get("thingClasses", [])] @@ -286,18 +360,18 @@ def render_plugin(plugin: dict) -> str: roles(tc), transport_of(tc), add_method(tc), str(len(tc.get("stateTypes", [])))] for _, tc in tcs] - out.append("#### Modèles pris en charge") - out.append(md_table(["Modèle", "Rôle", "Transport", "Ajout", "Grandeurs"], rows)) + out.append(REF["models_h"]) + out.append(md_table(REF["models_cols"], rows)) out.append("") - out.append("#### Détail par modèle") + out.append(REF["detail_h"]) for _, tc in tcs: title = tc.get("displayName", tc.get("name")) out.append(f'??? abstract "{title} — `{tc.get("name")}`"') body = [] body += render_params(tc) if tc.get("stateTypes"): - body.append("_Grandeurs mesurées :_") + body.append(REF["states_lbl"]) body.append("") body += render_states(tc) for ln in ("\n".join(body)).splitlines(): @@ -310,10 +384,10 @@ def render_nightly_stub(entry: dict, meta: dict) -> str: """Contenu généré pour une entrée nightly dont le JSON est absent.""" name = entry_name(entry, meta) return ( - f"_Support de **{name}** en cours d'intégration — canal nightly._\n" + f"_Support for **{name}** is being integrated — nightly channel._\n" "\n" - "_La documentation technique sera disponible dès que le plugin sera " - "disponible sur le dépôt stable._\n" + "_Technical documentation will be available once the plugin reaches " + "the stable repository._\n" ) @@ -321,7 +395,7 @@ def render_category_index(cat: str, entries: list, plugins: dict, src: Path) -> """Tableau listant les appareils d'une catégorie, pour l'index.md auto-généré.""" cat_entries = [e for e in entries if e.get("category") == cat and e.get("plugin")] if not cat_entries: - return "_Aucun appareil dans cette catégorie._\n" + return REF["cat_empty"] rows = [] for e in cat_entries: @@ -338,7 +412,7 @@ def render_category_index(cat: str, entries: list, plugins: dict, src: Path) -> rows.append([f"[{name}]({slug}.md)", protocol, channel_badge, origin_badge, stability_badge]) - return md_table(["Appareil", "Protocole", "Canal", "Origine", "Stabilité"], rows) + "\n" + return md_table(REF["cat_cols"], rows) + "\n" def render_matrix(entries: list, plugins: dict, src: Path) -> str: @@ -372,7 +446,7 @@ def render_matrix(entries: list, plugins: dict, src: Path) -> str: if not cat_rows: continue lines.append(f"## {label}\n") - lines.append("| Marque / Modèle | Protocole | Canal | Origine | Stabilité |") + lines.append("| " + " | ".join(REF["matrix_cols"]) + " |") lines.append("|---|---|---|---|---|") lines.extend(cat_rows) lines.append("") @@ -451,8 +525,14 @@ def validate_entries(entries: list, plugins: dict, src: Path) -> None: # ── Construction d'une page complète ───────────────────────────────────────── -def build_device_page(e: dict, plugin_data: dict | None, meta: dict) -> str: - """Construit le contenu complet d'une fiche appareil générée.""" +def build_device_page(e: dict, plugin_data: dict | None, meta: dict, + locale: str = DEFAULT_LOCALE) -> str: + """Construit le contenu complet d'une fiche appareil générée. + + `locale` ne change QUE la prose (SCAFFOLD) : le bloc de référence est + identique dans les trois langues, en anglais, par décision du 2026-10-12. + """ + sc = SCAFFOLD[locale] title = entry_name(e, meta) plugin = e["plugin"] fname = f"integrationplugin{plugin}.json" @@ -475,15 +555,16 @@ def build_device_page(e: dict, plugin_data: dict | None, meta: dict) -> str: header_parts = [f"# {title}", "", badges_block] if tagline: header_parts += ["", tagline] - header_parts += ["", "_Guide d'installation à venir._", "", "## Référence", ""] + header_parts += ["", sc["guide_todo"], "", sc["ref_h"], ""] header = "\n".join(header_parts) return header + "\n" + block + "\n" -def build_category_index_page(cat: str, entries: list, plugins: dict, src: Path) -> str: +def build_category_index_page(cat: str, entries: list, plugins: dict, src: Path, + locale: str = DEFAULT_LOCALE) -> str: """Construit le contenu complet d'un index.md de catégorie.""" - label = CATEGORY_LABELS.get(cat, cat.capitalize()) + label = CATEGORY_LABELS_I18N[locale].get(cat, cat.capitalize()) gen = render_category_index(cat, entries, plugins, src) # Même format que process() : f"{m.group(1)}\n{gen}\n{m.group(4)}" block = f"\n{gen}\n" @@ -493,56 +574,71 @@ def build_category_index_page(cat: str, entries: list, plugins: dict, src: Path) # ── Création des fichiers manquants ─────────────────────────────────────────── def ensure_device_pages(entries: list, plugins: dict, src: Path, - docs: Path, check: bool) -> int: - """Crée les fiches appareils inexistantes. Retourne 1 si manquant en mode --check.""" + docs: Path, check: bool, locales: list | None = None) -> int: + """Crée les fiches appareils inexistantes, dans CHAQUE langue. + + Une fiche créée dans la seule langue par défaut réapparaîtrait en français + sous /de/ et /en/ par le repli `fallback_to_default` — d'où la boucle. + Une fiche déjà présente n'est jamais réécrite : la prose traduite à la main + survit aux régénérations. + """ + locales = locales or LOCALES rc = 0 for e in entries: if not e.get("plugin"): continue slug = e.get("slug") or e["plugin"] folder = CATEGORY_FOLDER.get(e.get("category", ""), e.get("category", "autre")) - md_path = docs / "appareils" / folder / f"{slug}.md" - - if md_path.exists(): - continue + base = docs / "appareils" / folder fname = f"integrationplugin{e['plugin']}.json" plugin_data = plugins.get(fname) - meta = load_meta(src, e["plugin"]) - content = build_device_page(e, plugin_data, meta) + meta = None - if check: - print(f" MANQUANT : {md_path.relative_to(docs.parent)}") - rc = 1 - else: - md_path.parent.mkdir(parents=True, exist_ok=True) - md_path.write_text(content, encoding="utf-8") - print(f" CRÉÉ : {md_path.relative_to(docs.parent)}") + for locale in locales: + md_path = base / loc_name(slug, locale) + if md_path.exists(): + continue + if meta is None: + meta = load_meta(src, e["plugin"]) + content = build_device_page(e, plugin_data, meta, locale) + + if check: + print(f" MANQUANT : {md_path.relative_to(docs.parent)}") + rc = 1 + else: + md_path.parent.mkdir(parents=True, exist_ok=True) + md_path.write_text(content, encoding="utf-8") + print(f" CRÉÉ : {md_path.relative_to(docs.parent)}") return rc def ensure_category_indexes(entries: list, plugins: dict, src: Path, - docs: Path, check: bool) -> int: - """Crée les index.md de catégorie inexistants. Retourne 1 si manquant en --check.""" + docs: Path, check: bool, + locales: list | None = None) -> int: + """Crée les index.md de catégorie inexistants, dans CHAQUE langue.""" + locales = locales or LOCALES cats_present = {e.get("category") for e in entries if e.get("category")} rc = 0 - for cat in cats_present: + for cat in sorted(cats_present): folder = CATEGORY_FOLDER.get(cat, cat) - index_path = docs / "appareils" / folder / "index.md" + base = docs / "appareils" / folder - if index_path.exists(): - continue + for locale in locales: + index_path = base / loc_name("index", locale) + if index_path.exists(): + continue - content = build_category_index_page(cat, entries, plugins, src) + content = build_category_index_page(cat, entries, plugins, src, locale) - 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(content, encoding="utf-8") - print(f" CRÉÉ : {index_path.relative_to(docs.parent)}") + 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(content, encoding="utf-8") + print(f" CRÉÉ : {index_path.relative_to(docs.parent)}") return rc @@ -613,16 +709,37 @@ def process(docs: Path, entries: list, plugins: dict, src: Path, check: bool) -> # ── Point d'entrée ──────────────────────────────────────────────────────────── def main() -> int: + global DEFAULT_LOCALE # `--lang` peut la redéfinir (voir plus bas) ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--src", default=".plugins-src", type=Path, help="Dossier source des JSON (plat ou avec sous-dossiers repos)") ap.add_argument("--docs", default="docs", type=Path) - ap.add_argument("--lang", default="fr", help="Conservé pour compatibilité CI") + # `--lang` existait depuis la CI mais n'était PAS lu (« Conservé pour + # compatibilité CI »). Il est devenu réel au passage multi-langue : il + # désigne la langue par défaut, celle dont les fichiers n'ont pas de suffixe. + ap.add_argument("--lang", default=DEFAULT_LOCALE, choices=LOCALES, + help=f"Langue par défaut, fichiers sans suffixe (défaut : {DEFAULT_LOCALE})") + ap.add_argument("--locales", default=",".join(LOCALES), + help=f"Langues à générer, séparées par des virgules (défaut : {','.join(LOCALES)})") ap.add_argument("--check", action="store_true", help="CI : exit 1 si la doc n'est pas à jour") a = ap.parse_args() src = a.src + + DEFAULT_LOCALE = a.lang + locales = [x.strip() for x in a.locales.split(",") if x.strip()] + unknown = [x for x in locales if x not in SCAFFOLD] + if unknown: + print(f"ERREUR : langue(s) sans échafaudage SCAFFOLD : {', '.join(unknown)}", + file=sys.stderr) + return 2 + if DEFAULT_LOCALE not in locales: + print(f"ERREUR : --lang {DEFAULT_LOCALE} absent de --locales {','.join(locales)}", + file=sys.stderr) + return 2 + print(f"langues : {', '.join(locales)} · défaut (sans suffixe) : {DEFAULT_LOCALE}") + entries = load_porting_status(Path.cwd()) plugins = load_plugins(src) @@ -633,8 +750,8 @@ def main() -> int: validate_entries(entries, plugins, src) - rc_pages = ensure_device_pages(entries, plugins, src, a.docs, a.check) - rc_indexes = ensure_category_indexes(entries, plugins, src, a.docs, a.check) + rc_pages = ensure_device_pages(entries, plugins, src, a.docs, a.check, locales) + rc_indexes = ensure_category_indexes(entries, plugins, src, a.docs, a.check, locales) rc_summary = generate_summary(entries, a.docs, src, plugins, a.check) rc_process = process(a.docs, entries, plugins, src, a.check)