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)