powersync-docs/scripts/gen_device_reference.py
Patrick Schurig 846c0c7525 feat(i18n): traduire ce qu'on écrit, garder verbatim ce que la machine affiche
Signalé sur /de/appareils/bornes/goecharger/ : la page était allemande sur deux
lignes et anglaise sur tout le reste. Le diagnostic a séparé deux choses que
j'avais mises dans le même sac le 2026-10-12.

LA LIGNE DE PARTAGE, écrite en tête du générateur :

  • les LIBELLÉS QUE NOUS ÉCRIVONS (en-têtes de colonnes, titres de section,
    oui/non, « Documentation fabricant ») → TRADUITS. Ils ne désignent rien à
    l'écran, l'anglais n'y était qu'une friction. REF devient REF_I18N, trois
    jeux de 20 libellés.

  • le CONTENU VENANT DE NYMEA (noms de modèles, clés de registres, libellés de
    champs comme « MAC address », unités) → VERBATIM, en anglais. Ce sont
    exactement les chaînes que l'installateur lit DANS L'APP, qui tourne en
    en_US. Les traduire rendrait la doc inutilisable pour retrouver un champ à
    l'écran.

Et la pièce qui change l'expérience plus que la traduction elle-même : une note
`verbatim`, générée dans la langue de la page, en tête du bloc de référence.
Sans elle le mélange FR/EN passe pour une traduction bâclée ; avec elle c'est
une convention que le lecteur comprend.

L'injection devient sensible à la langue : `locale_of()` déduit la langue du
suffixe du fichier et `process()` rend le bloc en conséquence. Sans ça le même
bloc partait à l'identique dans les trois versions.

TAGLINES ET TITRES — i18n/devices.yaml, 52 entrées

Le tagline vivait en paragraphe libre, écrit UNE SEULE FOIS à la création de la
page : il restait donc en anglais sur les trois versions, y compris la
FRANÇAISE. Il entre dans le bloc badges, donc il est régénéré, donc traduisible
pour toujours. 52 taglines en fr et de ; l'anglais retombe sur l'amont.

Les traductions vivent dans le dépôt de DOC et pas dans les meta.json : trois
des cinq dépôts plugins sont des dépôts nymea amont que l'AGENTS.md interdit de
modifier. Repli sur l'amont pour toute entrée absente.

Deux titres s'affichaient en FRANÇAIS sous /de/ et /en/ — « Compteur ABB B2x »,
« Borne ABB Terra AC » : corrigés, dans la page et dans la nav.

Deux coquilles AMONT corrigées au passage, repérées en relisant les 57 entrées :
meta.json annonçait « Bestel » pour Vestel et « Easeex » pour Easee. Signalées
comme corrections, pas comme traductions, dans i18n/devices.yaml.

48 FICHES FR ÉTAIENT DANS UN ANCIEN FORMAT, ET MENTAIENT

Trouvé en vérifiant : 48 fiches FR n'avaient pas de marqueur `__badges_*__`.
Leurs badges étaient du HTML figé, écrit une fois — goecharger affichait
« NIGHTLY » alors que PORTING_STATUS dit `stable` depuis. Elles sont migrées au
format canonique : badges générés, donc exacts, plus titre localisé, tagline et
intertitre. Reste `hvac/homeconnect.md`, orpheline (plugin hors PORTING_STATUS).

NAV : UN CONSTAT QUI ÉVITE DE REFAIRE L'ESSAI

`literate-nav` ne lit que son `nav_file` : la substitution par suffixe de
mkdocs-static-i18n ne s'y applique PAS. Un `SUMMARY.de.md` généré n'était lu par
personne et la barre latérale allemande gardait les noms français. Testé, puis
retiré — et la raison est écrite dans le générateur pour que personne n'y
revienne. Les libellés de nav passent par `nav_translations` : 31 éléments
traduits par langue au lieu de 29.

RÉFÉRENCE API : LAISSÉE EN ANGLAIS, DÉLIBÉRÉMENT

Les 23 pages de l'API n'ont pas de variante par langue. Leur traduire les
libellés aurait fait voir du FRANÇAIS aux lecteurs /de/ et /en/ par le repli —
une régression. Créer 46 variantes pour traduire dix libellés aurait triplé
28 000 mots de contenu généré pour un gain nul : le reste de la page (noms de
méthodes, de champs, de types, descriptions) est l'API elle-même, et ce sont les
chaînes qu'on écrit dans ses requêtes. À la place, une note explique dans les
trois langues POURQUOI cette référence est en anglais.

Vérifié : build --strict sans avertissement, les deux générateurs rendent « Doc à
jour » en --check, et la fiche goecharger relue dans les trois langues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-13 08:42:21 +02:00

935 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
gen_device_reference.py — génère la matrice de compatibilité, les sections de
référence des appareils et le fichier de nav literate-nav (SUMMARY.md) depuis
PORTING_STATUS.yaml + integrationplugin*.json + meta.json.
Deux modes de traitement des fiches :
- Injection : remplace le contenu entre marqueurs dans les fiches existantes.
- Création : crée entièrement les fiches et index inexistants.
Marqueurs gérés :
<!-- BEGIN GENERATED: integrationplugin<plugin>.json --> fiche appareil
<!-- BEGIN GENERATED: __matrix__ --> matrice compatibilité
<!-- BEGIN GENERATED: __index_<cat>__ --> index de catégorie
Usage :
python3 scripts/gen_device_reference.py --src .plugins-src --docs docs --lang fr
python3 scripts/gen_device_reference.py --src .plugins-src --docs docs --lang fr --check
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError:
sys.exit("PyYAML est requis : pip install PyYAML")
# ═════════════════════════════════════════════════════════════════════════════
# LA LIGNE DE PARTAGE : on traduit ce qu'on ÉCRIT, on garde ce que la MACHINE
# affiche
#
# Révisé le 2026-10-13. Avant, tout le bloc de référence était en anglais dans
# les trois langues — ce qui donnait une page allemande anglaise à 90 %, et
# ressemblait à une traduction bâclée plutôt qu'à un choix.
#
# Deux couches, deux traitements :
#
# • Les LIBELLÉS QUE NOUS ÉCRIVONS (en-têtes de colonnes, titres de section,
# « oui/non ») → TRADUITS. Ils ne désignent rien à l'écran, l'anglais n'y
# était qu'une friction.
#
# • Le CONTENU VENANT DE NYMEA (noms de modèles, clés de registres, libellés
# de champs comme « MAC address », unités) → VERBATIM, en anglais. Ce sont
# exactement les chaînes que l'installateur lit DANS L'APP, qui tourne en
# en_US. Les traduire rendrait la doc inutilisable pour retrouver un champ à
# l'écran. Mesuré, pas supposé : Integrations.GetThingClasses rend la MÊME
# charge utile au sha256 près (243 340 octets) pour fr_FR, de_DE et en_US,
# et nymea-plugins-translations n'est pas installé sur le parc.
#
# La note `verbatim` ci-dessous dit cette convention au lecteur, dans sa langue,
# en tête du bloc de référence — sans quoi le mélange passe pour un défaut.
# ═════════════════════════════════════════════════════════════════════════════
INTERFACE_ROLE = {
"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": "Auto-discovery",
"user": "Manual",
"auto": "Automatic",
}
# Libellés du bloc de référence, PAR LANGUE (cf. la ligne de partage ci-dessus).
# Les valeurs de cellules (INTERFACE_ROLE, CREATE_METHOD) restent en anglais :
# elles décrivent ce que nymea expose, elles ne sont pas des libellés à nous.
REF_I18N = {
"fr": {
"manufacturer": "**Fabricant :**",
"plugin": "**Plugin :**",
"models_h": "#### Modèles pris en charge",
"models_cols": ["Modèle", "Rôle", "Transport", "Ajout", "Grandeurs"],
"detail_h": "#### Détail par modèle",
"discovery_lbl": "_Paramètres de découverte :_",
"discovery_cols": ["Clé", "Libellé", "Type", "Plage", "Défaut"],
"settings_lbl": "_Réglages :_",
"settings_cols": ["Clé", "Libellé", "Type", "Plage", "Défaut", "Lecture seule"],
"states_lbl": "_Grandeurs mesurées :_",
"states_cols": ["Clé", "Grandeur", "Type", "Unité"],
"yes": "oui",
"no": "non",
"cat_cols": ["Appareil", "Protocole", "Canal", "Origine", "Stabilité"],
"cat_empty": "_Aucun appareil dans cette catégorie._\n",
"matrix_cols": ["Marque / Modèle", "Protocole", "Canal", "Origine", "Stabilité"],
"verbatim": "!!! info \"Libellés de l'application\"\n"
" Les clés et libellés ci-dessous sont ceux affichés par\n"
" l'application, **en anglais**. Ils sont laissés tels quels pour\n"
" que vous les retrouviez à l'identique à l'écran.\n",
"manual_lbl": "Documentation fabricant",
"manual_word": "Manuel",
"nightly": "_Prise en charge de **{name}** en cours d'intégration — canal nightly._\n"
"\n"
"_La documentation technique sera disponible dès que le plugin\n"
"atteindra le dépôt stable._\n",
},
"de": {
"manufacturer": "**Hersteller:**",
"plugin": "**Plugin:**",
"models_h": "#### Unterstützte Modelle",
"models_cols": ["Modell", "Rolle", "Transport", "Einbindung", "Messwerte"],
"detail_h": "#### Detail je Modell",
"discovery_lbl": "_Erkennungsparameter:_",
"discovery_cols": ["Schlüssel", "Bezeichnung", "Typ", "Bereich", "Standard"],
"settings_lbl": "_Einstellungen:_",
"settings_cols": ["Schlüssel", "Bezeichnung", "Typ", "Bereich", "Standard", "Nur lesen"],
"states_lbl": "_Gemessene Größen:_",
"states_cols": ["Schlüssel", "Größe", "Typ", "Einheit"],
"yes": "ja",
"no": "nein",
"cat_cols": ["Gerät", "Protokoll", "Kanal", "Herkunft", "Stabilität"],
"cat_empty": "_Kein Gerät in dieser Kategorie._\n",
"matrix_cols": ["Marke / Modell", "Protokoll", "Kanal", "Herkunft", "Stabilität"],
"verbatim": "!!! info \"Bezeichnungen der Anwendung\"\n"
" Die Schlüssel und Bezeichnungen unten sind die der Anwendung,\n"
" **auf Englisch**. Sie bleiben unverändert, damit Sie sie auf dem\n"
" Bildschirm wortgleich wiederfinden.\n",
"manual_lbl": "Herstellerdokumentation",
"manual_word": "Handbuch",
"nightly": "_Unterstützung für **{name}** wird gerade integriert — Kanal nightly._\n"
"\n"
"_Die technische Dokumentation folgt, sobald das Plugin das\n"
"stabile Repository erreicht._\n",
},
"en": {
"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"],
"verbatim": "!!! info \"Application labels\"\n"
" The keys and labels below are the ones the application shows,\n"
" **in English**. They are left verbatim so that you find them\n"
" word for word on screen.\n",
"manual_lbl": "Manufacturer documentation",
"manual_word": "Manual",
"nightly": "_Support for **{name}** is being integrated — nightly channel._\n"
"\n"
"_Technical documentation will follow once the plugin reaches the\n"
"stable repository._\n",
},
}
CATEGORY_LABELS = {
"compteur": "Compteurs",
"irve": "Bornes de recharge",
"smartdevice": "SmartDevices",
"hvac": "HVAC",
"onduleur": "Onduleurs / PV",
"batterie": "Batteries / ESS",
"tarif": "Tarifs & prévisions",
"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",
},
}
# ═════════════════════════════════════════════════════════════════════════════
# TRADUCTIONS DES TITRES ET TAGLINES — côté DOC, pas côté amont
#
# Les `name` viennent de PORTING_STATUS.yaml et les `tagline` des meta.json des
# cinq dépôts plugins. Trois de ces cinq sont des dépôts nymea AMONT, que
# l'AGENTS.md du workspace interdit de modifier — les traductions ne peuvent donc
# pas y vivre. Elles vivent ici, dans `i18n/devices.yaml`, et le générateur
# retombe sur l'amont pour toute entrée absente.
#
# Forme : <plugin>:
# name: {fr: …, de: …, en: …}
# tagline: {fr: …, de: …, en: …}
# Toute langue omise retombe sur la source amont.
# ═════════════════════════════════════════════════════════════════════════════
DEVICE_I18N: dict = {}
def load_device_i18n(root: Path) -> dict:
"""Charge i18n/devices.yaml. Absent = pas de traduction, on garde l'amont."""
path = root / "i18n" / "devices.yaml"
if not path.exists():
return {}
raw = path.read_text(encoding="utf-8").strip()
if not raw:
return {}
try:
return yaml.safe_load(raw) or {}
except yaml.YAMLError as exc:
sys.exit(f"ERREUR : i18n/devices.yaml non parsable ({exc})")
def i18n_field(plugin: str, field: str, locale: str) -> str | None:
"""Traduction d'un champ pour une langue, ou None s'il n'y en a pas."""
entry = DEVICE_I18N.get(plugin) or {}
val = (entry.get(field) or {}).get(locale)
return val.strip() if isinstance(val, str) and val.strip() else None
def locale_of(md: Path) -> str:
"""Langue d'un fichier d'après son suffixe : x.de.md → de, x.md → défaut.
C'est la pièce qui rend l'injection sensible à la langue : sans elle, le
même bloc généré partait à l'identique dans les trois versions.
"""
parts = md.name.split(".")
if len(parts) >= 3 and parts[-2] in REF_I18N:
return parts[-2]
return DEFAULT_LOCALE
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",
"smartdevice": "smart",
"hvac": "hvac",
"onduleur": "onduleurs",
"batterie": "batteries",
"tarif": "tarifs",
"generic": "generic",
}
CATEGORY_ORDER = [
"compteur", "irve", "smartdevice", "hvac", "onduleur", "batterie", "tarif", "generic",
]
CHANNEL_BADGES = {
"stable": '<span class="badge stable">STABLE</span>',
"testing": '<span class="badge testing">TESTING</span>',
"nightly": '<span class="badge nightly">NIGHTLY</span>',
}
STABILITY_BADGES = {
"consumer": '<span class="badge consumer">CONSUMER</span>',
"community": '<span class="badge community">COMMUNITY</span>',
"experimental": '<span class="badge experimental">EXPERIMENTAL</span>',
}
ORIGIN_BADGES = {
"nymea": '<span class="badge origin-nymea">NYMEA</span>',
"etm": '<span class="badge origin-etm">ETM</span>',
"tiers": '<span class="badge origin-tiers">TIERS</span>',
}
def resolve_origin(e: dict) -> str:
"""Détermine l'origine depuis le champ explicite `origin:` ou depuis `repo:`."""
if e.get("origin"):
return e["origin"]
repo = e.get("repo", "")
if repo.startswith("nymea-"):
return "nymea"
if repo.startswith("etm-"):
return "etm"
return "tiers"
def render_header_badges(e: dict, meta: dict, locale: str = DEFAULT_LOCALE) -> str:
"""Badges d'en-tête (canal, origine, stabilité) PUIS le tagline.
Le tagline est entré dans ce bloc généré le 2026-10-13. Il vivait avant en
paragraphe libre, écrit une seule fois à la création de la page : il restait
donc dans la langue de l'amont (anglais) sur les trois versions — y compris
la française. Dans le bloc, il est régénéré, donc traduisible pour toujours.
"""
channel_badge = CHANNEL_BADGES.get(e["channel"], e["channel"])
origin_badge = ORIGIN_BADGES.get(resolve_origin(e), "")
stability = meta.get("stability", "")
stability_badge = STABILITY_BADGES.get(stability, "") if stability else ""
parts = [b for b in [channel_badge, origin_badge, stability_badge] if b]
out = " ".join(parts) + "\n"
tagline = i18n_field(e.get("plugin", ""), "tagline", locale) or meta.get("tagline", "")
if tagline:
out += "\n" + tagline.strip() + "\n"
return out
MARKER_RE = re.compile(
r"(<!-- BEGIN GENERATED: (?P<key>[^\s]+) -->)(?P<body>.*?)(<!-- END GENERATED -->)",
re.DOTALL,
)
# ── Chargement ────────────────────────────────────────────────────────────────
def load_porting_status(root: Path) -> list:
path = root / "PORTING_STATUS.yaml"
if not path.exists():
sys.exit(f"ERREUR : PORTING_STATUS.yaml introuvable à {path}")
with open(path) as f:
return yaml.safe_load(f)
def load_plugins(src: Path) -> dict:
"""Charge tous les integrationplugin*.json trouvés récursivement.
Logue explicitement chaque fichier vide ou non parsable (ne plante pas).
"""
plugins: dict = {}
for f in sorted(src.rglob("integrationplugin*.json")):
if f.name in plugins:
continue
raw = f.read_text(encoding="utf-8").strip()
if not raw:
print(f" AVERTISSEMENT : {f} est vide — ignoré", file=sys.stderr)
continue
try:
plugins[f.name] = json.loads(raw)
except json.JSONDecodeError as exc:
print(f" AVERTISSEMENT : {f} non parsable ({exc}) — ignoré", file=sys.stderr)
return plugins
def load_meta(src: Path, plugin: str) -> dict:
"""Charge meta.json depuis le même dossier que integrationplugin<plugin>.json.
Logue explicitement si le fichier est vide ou non parsable.
"""
for f in sorted(src.rglob(f"integrationplugin{plugin}.json")):
meta_path = f.parent / "meta.json"
if not meta_path.exists():
return {}
raw = meta_path.read_text(encoding="utf-8").strip()
if not raw:
print(f" AVERTISSEMENT : {meta_path} est vide — meta ignoré", file=sys.stderr)
return {}
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
print(f" AVERTISSEMENT : {meta_path} non parsable ({exc}) — meta ignoré",
file=sys.stderr)
return {}
return {}
# ── Libellé d'un appareil ────────────────────────────────────────────────────
def entry_name(e: dict, meta: dict, locale: str = DEFAULT_LOCALE) -> str:
"""Titre : i18n/devices.yaml > PORTING_STATUS.name > meta.json.title > plugin.
La traduction passe AVANT PORTING_STATUS.name, sinon un nom français
(« Compteur ABB B2x ») s'afficherait tel quel sous /de/ et /en/.
"""
return (i18n_field(e.get("plugin", ""), "name", locale)
or e.get("name") or meta.get("title") or e.get("plugin", "?"))
def render_manual_url(e: dict, meta: dict, locale: str = DEFAULT_LOCALE) -> str:
"""Section 'Documentation fabricant' si manual_url est présent. Libellé traduit."""
url = e.get("manual_url", "")
if not url:
return ""
R = REF_I18N[locale]
name = entry_name(e, meta, locale)
return f"\n**{R['manual_lbl']}** : [{R['manual_word']} {name}]({url})\n"
# ── Helpers JSON nymea ────────────────────────────────────────────────────────
def transport_of(tc: dict) -> str:
params = {p["name"] for p in tc.get("paramTypes", [])} | \
{p["name"] for p in tc.get("discoveryParamTypes", [])}
ifaces = set(tc.get("interfaces", []))
if "networkdevice" in ifaces or {"hostName", "address", "port", "macAddress"} & params:
return "Modbus TCP"
if {"modbusMasterUuid", "rtuMaster"} & params:
return "Modbus RTU"
return "—"
def roles(tc: dict) -> str:
r = [INTERFACE_ROLE.get(i, i) for i in tc.get("interfaces", []) if i not in TECH_INTERFACES]
return ", ".join(r) if r else "—"
def add_method(tc: dict) -> str:
return " / ".join(CREATE_METHOD.get(m, m) for m in tc.get("createMethods", [])) or "—"
def resolve_protocol(plugin_data: dict | None) -> str:
if not plugin_data:
return "—"
for v in plugin_data.get("vendors", []):
for tc in v.get("thingClasses", []):
t = transport_of(tc)
if t != "—":
return t
return "—"
# ── Rendu Markdown ────────────────────────────────────────────────────────────
def fmt_range(p: dict) -> str:
lo, hi = p.get("minValue"), p.get("maxValue")
if lo is not None and hi is not None:
return f"{lo}–{hi}"
return "—"
def fmt_default(p: dict) -> str:
d = p.get("defaultValue", "")
if d == "" or d is None:
return "—"
return f"`{d}`"
def md_table(headers: list, rows: list) -> str:
out = ["| " + " | ".join(headers) + " |",
"| " + " | ".join("---" for _ in headers) + " |"]
for r in rows:
out.append("| " + " | ".join(str(c) for c in r) + " |")
return "\n".join(out)
def render_params(tc: dict, locale: str = DEFAULT_LOCALE) -> list:
R = REF_I18N[locale]
lines = []
disc = tc.get("discoveryParamTypes", [])
if disc:
rows = [[f"`{p['name']}`", p.get("displayName", ""), p.get("type", ""),
fmt_range(p), fmt_default(p)] for p in disc]
lines.append(R["discovery_lbl"])
lines.append("")
lines.append(md_table(R["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),
R["yes"] if p.get("readOnly") else R["no"]] for p in settings]
lines.append(R["settings_lbl"])
lines.append("")
lines.append(md_table(R["settings_cols"], rows))
lines.append("")
return lines
def render_states(tc: dict, locale: str = DEFAULT_LOCALE) -> list:
R = REF_I18N[locale]
states = tc.get("stateTypes", [])
if not states:
return []
rows = [[f"`{s['name']}`", s.get("displayName", ""), s.get("type", ""),
s.get("unit") or "—"] for s in states]
return [md_table(R["states_cols"], rows), ""]
def render_plugin(plugin: dict, locale: str = DEFAULT_LOCALE) -> str:
R = REF_I18N[locale]
out = []
# La convention de verbatim, dite au lecteur dans sa langue, AVANT les
# tableaux : sans elle le mélange FR/EN passe pour une traduction bâclée.
out.append(R["verbatim"])
vendors = plugin.get("vendors", [])
vname = ", ".join(v.get("displayName", v.get("name", "")) for v in vendors)
out.append(f'{R["manufacturer"]} {vname} ')
out.append(f'{R["plugin"]} `{plugin.get("name", "")}`')
out.append("")
tcs = [(v, tc) for v in vendors for tc in v.get("thingClasses", [])]
rows = [[f"**{tc.get('displayName', tc.get('name'))}**",
roles(tc), transport_of(tc), add_method(tc),
str(len(tc.get("stateTypes", [])))]
for _, tc in tcs]
out.append(R["models_h"])
out.append(md_table(R["models_cols"], rows))
out.append("")
out.append(R["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, locale)
if tc.get("stateTypes"):
body.append(R["states_lbl"])
body.append("")
body += render_states(tc, locale)
for ln in ("\n".join(body)).splitlines():
out.append(" " + ln if ln else "")
out.append("")
return "\n".join(out).rstrip() + "\n"
def render_nightly_stub(entry: dict, meta: dict, locale: str = DEFAULT_LOCALE) -> str:
"""Contenu généré pour une entrée nightly dont le JSON est absent."""
name = entry_name(entry, meta, locale)
return REF_I18N[locale]["nightly"].format(name=name)
def render_category_index(cat: str, entries: list, plugins: dict, src: Path,
locale: str = DEFAULT_LOCALE) -> str:
"""Tableau listant les appareils d'une catégorie, pour l'index.md auto-généré."""
R = REF_I18N[locale]
cat_entries = [e for e in entries if e.get("category") == cat and e.get("plugin")]
if not cat_entries:
return R["cat_empty"]
rows = []
for e in cat_entries:
fname = f"integrationplugin{e['plugin']}.json"
plugin_data = plugins.get(fname)
meta = load_meta(src, e["plugin"])
name = entry_name(e, meta, locale)
slug = e.get("slug") or e["plugin"]
protocol = resolve_protocol(plugin_data) if plugin_data else "—"
channel_badge = CHANNEL_BADGES.get(e["channel"], e["channel"])
origin_badge = ORIGIN_BADGES.get(resolve_origin(e), "—")
stability = meta.get("stability", "")
stability_badge = STABILITY_BADGES.get(stability, "—") if stability else "—"
rows.append([f"[{name}]({slug}.md)", protocol, channel_badge, origin_badge,
stability_badge])
return md_table(R["cat_cols"], rows) + "\n"
def render_matrix(entries: list, plugins: dict, src: Path,
locale: str = DEFAULT_LOCALE) -> str:
R = REF_I18N[locale]
by_cat: dict = {}
for e in entries:
by_cat.setdefault(e.get("category", "autre"), []).append(e)
lines = []
ordered_cats = [c for c in CATEGORY_ORDER if c in by_cat]
ordered_cats += [c for c in by_cat if c not in CATEGORY_ORDER]
for cat in ordered_cats:
label = CATEGORY_LABELS.get(cat, cat.capitalize())
cat_rows = []
for e in by_cat[cat]:
fname = f"integrationplugin{e['plugin']}.json" if e.get("plugin") else None
plugin_data = plugins.get(fname) if fname else None
if e["channel"] == "nightly" and (not fname or fname not in plugins):
print(f" INFO matrice : {e.get('plugin', '?')} nightly sans JSON "
f"→ ligne stub dans la matrice", file=sys.stderr)
meta = load_meta(src, e["plugin"]) if e.get("plugin") else {}
name = entry_name(e, meta, locale)
protocol = resolve_protocol(plugin_data)
channel_badge = CHANNEL_BADGES.get(e["channel"], e["channel"])
origin_badge = ORIGIN_BADGES.get(resolve_origin(e), "—")
stability = meta.get("stability", "")
stability_badge = STABILITY_BADGES.get(stability, "—") if stability else "—"
cat_rows.append(
f"| {name} | {protocol} | {channel_badge} | {origin_badge} | {stability_badge} |"
)
if not cat_rows:
continue
lines.append(f"## {label}\n")
lines.append("| " + " | ".join(R["matrix_cols"]) + " |")
lines.append("|---|---|---|---|---|")
lines.extend(cat_rows)
lines.append("")
return "\n".join(lines)
# ── SUMMARY.md pour mkdocs-literate-nav ──────────────────────────────────────
def generate_summary(entries: list, docs: Path, src: Path, plugins: dict,
check: bool, locale: str | None = None) -> int:
"""Écrit le SUMMARY.md d'une langue. Retourne 1 en mode --check si périmé.
`locale` vaut DEFAULT_LOCALE par défaut ; une autre langue écrit
`SUMMARY.<locale>.md`, que mkdocs-static-i18n substitue au moment de son
build. Sans ça, la barre latérale allemande afficherait les noms français
des appareils — c'est ce qu'elle faisait pour « Compteur ABB B2x ».
"""
locale = locale or DEFAULT_LOCALE
by_cat: dict = {}
for e in entries:
by_cat.setdefault(e.get("category", "autre"), []).append(e)
compat = {"fr": "Compatibilité", "de": "Kompatibilität", "en": "Compatibility"}[locale]
lines = [f"* [{compat}](compatibilite.md)"]
ordered_cats = [c for c in CATEGORY_ORDER if c in by_cat]
ordered_cats += [c for c in by_cat if c not in CATEGORY_ORDER]
for cat in ordered_cats:
label = CATEGORY_LABELS_I18N[locale].get(cat, cat.capitalize())
folder = CATEGORY_FOLDER.get(cat, cat)
cat_entries = []
for e in by_cat[cat]:
if not e.get("plugin"):
continue
meta = load_meta(src, e["plugin"])
name = entry_name(e, meta, locale)
slug = e.get("slug") or e["plugin"]
cat_entries.append(f" * [{name}]({folder}/{slug}.md)")
if cat_entries:
lines.append(f"* [{label}]({folder}/index.md)")
lines.extend(cat_entries)
new_content = "\n".join(lines) + "\n"
summary_path = docs / "appareils" / loc_name("SUMMARY", locale)
if check:
if not summary_path.exists() or summary_path.read_text(encoding="utf-8") != new_content:
print(f" SUMMARY.md périmé : {summary_path.relative_to(docs.parent)}")
return 1
return 0
summary_path.write_text(new_content, encoding="utf-8")
print(f"SUMMARY.md → {summary_path.relative_to(docs.parent)}")
return 0
# ── Validation ────────────────────────────────────────────────────────────────
def validate_entries(entries: list, plugins: dict, src: Path) -> None:
errors = []
for e in entries:
if not e.get("plugin"):
continue
fname = f"integrationplugin{e['plugin']}.json"
if e["channel"] != "nightly":
if fname not in plugins:
errors.append(
f" BLOQUANT : {fname} introuvable dans --src "
f"(channel={e['channel']}, plugin={e['plugin']})"
)
meta = load_meta(src, e["plugin"])
if not e.get("name") and not meta.get("title"):
errors.append(
f" BLOQUANT : libellé manquant pour plugin={e['plugin']} "
f"(ni PORTING_STATUS.name ni meta.json.title)"
)
if errors:
sys.exit("ERREURS BLOQUANTES :\n" + "\n".join(errors))
# ── Construction d'une page complète ─────────────────────────────────────────
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, locale)
plugin = e["plugin"]
fname = f"integrationplugin{plugin}.json"
badges_gen = render_header_badges(e, meta, locale)
badges_block = (
f"<!-- BEGIN GENERATED: __badges_{plugin}__ -->\n{badges_gen}\n<!-- END GENERATED -->"
)
if plugin_data:
gen = render_plugin(plugin_data, locale) + render_manual_url(e, meta, locale)
else:
print(f" INFO : {fname} absent (nightly) → page stub pour {title}",
file=sys.stderr)
gen = render_nightly_stub(e, meta, locale)
block = f"<!-- BEGIN GENERATED: {fname} -->\n{gen}\n<!-- END GENERATED -->"
# Le tagline vit désormais DANS le bloc badges (render_header_badges), donc
# il n'est plus recopié ici : sinon il apparaîtrait deux fois.
header_parts = [f"# {title}", "", badges_block]
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,
locale: str = DEFAULT_LOCALE) -> str:
"""Construit le contenu complet d'un index.md de catégorie."""
label = CATEGORY_LABELS_I18N[locale].get(cat, cat.capitalize())
gen = render_category_index(cat, entries, plugins, src, locale)
# Même format que process() : f"{m.group(1)}\n{gen}\n{m.group(4)}"
block = f"<!-- BEGIN GENERATED: __index_{cat}__ -->\n{gen}\n<!-- END GENERATED -->"
return f"---\nhide:\n - toc\n---\n\n# {label}\n\n{block}\n"
# ── Création des fichiers manquants ───────────────────────────────────────────
def ensure_device_pages(entries: list, plugins: dict, src: Path,
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"))
base = docs / "appareils" / folder
fname = f"integrationplugin{e['plugin']}.json"
plugin_data = plugins.get(fname)
meta = None
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,
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 sorted(cats_present):
folder = CATEGORY_FOLDER.get(cat, cat)
base = docs / "appareils" / folder
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, 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)}")
return rc
# ── Traitement des fichiers MD (injection dans les marqueurs) ─────────────────
def process(docs: Path, entries: list, plugins: dict, src: Path, check: bool) -> int:
entry_by_plugin = {e["plugin"]: e for e in entries if e.get("plugin")}
changed = []
for md in sorted(docs.rglob("*.md")):
text = md.read_text(encoding="utf-8")
if "<!-- BEGIN GENERATED:" not in text:
continue
loc = locale_of(md)
def repl(m: re.Match) -> str:
key = m.group("key")
if key == "__matrix__":
gen = render_matrix(entries, plugins, src, loc)
elif key.startswith("__badges_"):
plugin_name = key.removeprefix("__badges_").removesuffix("__")
entry = entry_by_plugin.get(plugin_name)
if not entry:
print(f" ! {md.name}: clé '{key}' plugin '{plugin_name}' inconnu",
file=sys.stderr)
return m.group(0)
gen = render_header_badges(entry, load_meta(src, plugin_name), loc)
elif key.startswith("__index_"):
# key = "__index_<cat>__" → strip prefix "__index_" et suffix "__"
cat = key.removeprefix("__index_").removesuffix("__")
gen = render_category_index(cat, entries, plugins, src, loc)
elif key in plugins:
plugin_name = key.removeprefix("integrationplugin").removesuffix(".json")
entry = entry_by_plugin.get(plugin_name, {})
meta_e = load_meta(src, plugin_name) if entry else {}
gen = render_plugin(plugins[key], loc) + render_manual_url(entry, meta_e, loc)
else:
# Clé plugin absente : nightly sans JSON → stub ; sinon avertissement
plugin_name = key.removeprefix("integrationplugin").removesuffix(".json")
entry = entry_by_plugin.get(plugin_name)
if entry and entry.get("channel") == "nightly":
print(f" INFO : {key} absent (nightly) → stub dans {md.name}",
file=sys.stderr)
gen = render_nightly_stub(entry, load_meta(src, plugin_name), loc)
else:
print(f" ! {md.name}: clé '{key}' absente de --src", file=sys.stderr)
return m.group(0)
return f"{m.group(1)}\n{gen}\n{m.group(4)}"
new = MARKER_RE.sub(repl, text)
if new != text:
changed.append(md)
if not check:
md.write_text(new, encoding="utf-8")
if check:
if changed:
print("Doc PAS à jour :", ", ".join(str(c) for c in changed))
return 1
print("Doc à jour.")
return 0
print(f"Régénéré : {len(changed)} page(s).")
for c in changed:
print(f" - {c.relative_to(docs)}")
return 0
# ── 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)
# `--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}")
global DEVICE_I18N
DEVICE_I18N = load_device_i18n(Path.cwd())
if DEVICE_I18N:
print(f"i18n appareils : {len(DEVICE_I18N)} entrée(s) traduite(s)")
entries = load_porting_status(Path.cwd())
plugins = load_plugins(src)
if not plugins:
print(f"Aucun integrationplugin*.json trouvé dans {src}", file=sys.stderr)
return 2
print(f"{len(plugins)} plugin(s) chargé(s) : {', '.join(plugins)}")
validate_entries(entries, plugins, src)
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)
# UN SEUL SUMMARY, dans la langue par défaut — et c'est un CONSTAT, pas un
# oubli. `literate-nav` ne lit que le `nav_file` configuré (`SUMMARY.md`) :
# la substitution par suffixe de mkdocs-static-i18n ne s'y applique PAS.
# Testé le 2026-10-13 : un `SUMMARY.de.md` généré n'était lu par personne et
# la barre latérale allemande gardait les noms français.
# Les libellés de nav se traduisent donc par `nav_translations` dans
# mkdocs.yml — c'est le mécanisme que le thème fournit pour ça.
rc_summary = generate_summary(entries, a.docs, src, plugins, a.check)
rc_process = process(a.docs, entries, plugins, src, a.check)
return max(rc_pages, rc_indexes, rc_summary, rc_process)
if __name__ == "__main__":
raise SystemExit(main())