diff --git a/.gitignore b/.gitignore
index 8283515..e2fae28 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,5 @@ build/
.artifacts/
# Built by tools/build_site_artifacts.py from the source repositories. Codeplugs
# are disposable artifacts, so CI regenerates them rather than serving a commit.
-site/codeplugs/
\ No newline at end of file
+site/codeplugs/
+site/*/codeplugs/
\ No newline at end of file
diff --git a/site/codeplug-sources.json b/site/codeplug-sources.json
index c3bfea9..71b9292 100644
--- a/site/codeplug-sources.json
+++ b/site/codeplug-sources.json
@@ -19,13 +19,23 @@
"",
"Which file formats a profile produces is NOT listed here. It comes from",
"the radio's row in site/radios.json, so a radio cannot advertise an",
- "export column in the support table and then fail to offer the file."
+ "export column in the support table and then fail to offer the file.",
+ "",
+ "'i18n' holds per-language overrides of 'name' and 'description' for the",
+ "translated downloads pages; a language without an entry falls back to",
+ "the English text."
],
"sources": [
{
"id": "use-cases",
"name": "Use-case starter codeplugs",
"description": "Buy a supported radio and make it behave like the radio you wanted: a GMRS handheld, a PMR446 handheld, a MURS handheld, a marine VHF receiver, or all of them at once. Built from published ssrf-lite data only, with no personal DMR IDs. GMRS needs an FCC licence and the marine channels are receive-only; see the reference sheet in each download.",
+ "i18n": {
+ "es": {
+ "name": "Codeplugs iniciales por caso de uso",
+ "description": "Compra una radio compatible y haz que se comporte como la radio que querías: un portátil GMRS, un portátil PMR446, un portátil MURS, un receptor VHF marino, o todos a la vez. Compilados solo con datos publicados de ssrf-lite, sin IDs DMR personales. GMRS requiere una licencia de la FCC y los canales marinos son solo de recepción; consulta la hoja de referencia en cada descarga."
+ }
+ },
"repo": "https://github.com/Chicago-Offline/codeplugger",
"profiles": [
"examples/profiles/baofeng_bf888s/gmrs.yml",
@@ -40,6 +50,12 @@
"id": "chioff-shared",
"name": "Chicago Offline community net",
"description": "Shared GMRS and MURS channels for the Chicago Offline community net. Instance-agnostic: no personal DMR IDs, no per-operator settings.",
+ "i18n": {
+ "es": {
+ "name": "Red comunitaria de Chicago Offline",
+ "description": "Canales GMRS y MURS compartidos de la red comunitaria de Chicago Offline. Independientes de la instancia: sin IDs DMR personales, sin ajustes por operador."
+ }
+ },
"repo": "https://github.com/Chicago-Offline/chioff-codeplugger-profiles-shared",
"checkout": "chioff-codeplugger-profiles-shared",
"ssrf_roots": [
diff --git a/site/es/index.html b/site/es/index.html
new file mode 100644
index 0000000..ac28eeb
--- /dev/null
+++ b/site/es/index.html
@@ -0,0 +1,592 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ codeplugger — un compilador de codeplugs para flotas de radios
+
+
+
+
+
+
Programar una radio debería ser una compilación, no una sesión de CPS.
+
+ codeplugger combina datos de RF compartidos con tu perfil bajo
+ control de versiones para compilar un codeplug validado y neutral
+ de radio. Expórtalo, o escríbelo directamente en una radio compatible.
+
+ Los hechos y las preferencias se mantienen separados. Los datos de RF
+ describen lo que existe; un perfil describe lo que quieres en una
+ radio concreta. Todo lo intermedio es determinista y revisable.
+
+
+
+
Datos SSRF
+
Planes de canales, repetidores y talkgroups públicos de SSRF-Lite, combinados con capas compartidas o privadas por ID de registro estable.
+
+
+
Perfil
+
Tu política en YAML: qué asignaciones, en qué orden, en qué zonas, con listas de escaneo, grupos RX, contactos y ajustes de la radio. Los perfiles pueden heredar unos de otros.
+
+
+
Resolver y validar
+
codeplugger une las entradas y comprueba cada canal contra las capacidades de la radio de destino: bandas, modos, longitud de nombres, límites de zonas y listas, capacidad dependiente del firmware.
+
+
+
Exportar o programar
+
Exporta CHIRP CSV o qdmr YAML; aplica un perfil a una línea base P4 decodificada para TOML; o usa un backend con confirmación para escribir en una radio compatible.
+
+
+
+
+
+
+
+
Codeplugs comunitarios precompilados
+
+ CI los compila a partir de perfiles comunitarios públicos y datos de
+ RF a medida que cambian las fuentes, y luego publica archivos de
+ importación, referencias imprimibles y sumas de verificación SHA-256.
+ No hace falta instalar codeplugger.
+
+ La tabla combina las capacidades de cada radio con los detalles de
+ exportación y verificación en hardware registrados en el repositorio.
+ Su generador comprueba que cada entrada
+ radios/*/capabilities.json tenga una fila correspondiente.
+
+
+
+
+
+
Radio
+
Modos
+
CHIRP CSV exportación
+
Importar en qdmr YAML
+
Importar en p64tool TOML
+
Importar en CPS del fabricante archivos propietarios
+ hw verificado en una radio física ·
+ ✓ compatible hoy ·
+ — no aplicable o no implementado
+
+
+
+
CHIRP CSV
+
Canales FM analógicos y AM de solo recepción en el orden del perfil, comprobados contra los límites de la radio. Importa y carga en CHIRP donde exista un controlador, o usa chirp-writer en las radios compatibles.
+
+
+
qdmr YAML
+
Codeplug extensible de qdmr con zonas, listas de escaneo, listas de grupos RX, contactos y varios IDs DMR. Los ajustes nativos de la radio pasan a través de extensions.qdmr.
+
+
+
p64tool TOML
+
Se aplica sobre una línea base de p64tool decode para que los ajustes no tocados sobrevivan, y luego se vuelve a codificar para la P4.
+
+
+
plan benlink
+
Un plan de canales declarativo para portátiles con protocolo Benshi, que no tienen CPS de escritorio ni controlador CHIRP: la única vía de entrada es Bluetooth. Las zonas se asignan a las regiones de la radio, y las regiones que el plan omite nunca se escriben.
+
+
+
Escritura directa
+
Las escrituras requieren confirmación explícita y comprobaciones de seguridad propias de cada backend. La copia de seguridad y la relectura varían según el backend; las operaciones pueden registrarse. Backends: dmrconf, p64tool, chirp-writer y benlink por Bluetooth.
+
+
+
+ Planificado: ampliar la cobertura de radios y formatos de exportación sin cambiar la resolución central de perfiles.
+
+
+
+
+
+
+
Funciones
+
Hecho para quienes mantienen más de una radio coherente y quieren saber exactamente qué entró en cada una.
+
+
+
Compilaciones deterministas
+
Las mismas entradas siempre producen el mismo codeplug, hasta un SHA-256 estable. Los archivos generados son artefactos desechables, nunca una fuente de datos.
+
+
+
Validación de capacidades
+
Bandas, rangos TX frente a solo RX, modos, ancho de banda, longitud de nombres, límites de zonas y listas, y capacidad de contactos dependiente del firmware, clasificados como aviso / advertencia / crítico, con todos los críticos informados en una sola ejecución.
+
+
+
Escrituras con confirmación
+
Confirmación explícita, comprobaciones de identidad propias de cada backend y registros de escritura; verificación previa y relectura donde esté disponible.
+
+
+
Zonas, listas de escaneo, grupos RX, contactos
+
Los talkgroups son hechos SSRF; la pertenencia y el contacto TX, grupo RX y lista de escaneo por asignación son política del perfil, todo aplicado contra los límites de la radio.
+
+
+
Herencia de perfiles
+
Comparte un perfil base y deriva variantes con extends, zones_only y omit_zones. Un perfil base que no nombra ninguna radio es reutilizable en cualquier radio con capacidad para él. Revisa el resultado combinado con --print-merged-profile.
+
+
+
IDs DMR de identidad múltiple
+
Una instancia de radio puede llevar varios IDs DMR; los perfiles vinculan uno por zona o por asignación y los exportadores emiten solo las identidades realmente usadas.
+
+
+
Registro de flota y simulación
+
Un registro de instancias asocia radios físicas a perfiles. codeplugger-fleet resuelve y valida cada radio e informa de su estado antes de escribir nada.
+
+
+
Hechos de canal derivados
+
Ancho de banda a partir del designador de emisión de la UIT, modo AM para asignaciones de banda aérea de solo recepción, nombres de pantalla sobrescritos desde capas.
+
+
+
Artefactos de referencia
+
Referencias de canales en HTML y Markdown más registros de operación por instancia de radio, para que lo que hay en la radio esté siempre documentado.
+
+
+
Extensiones con espacio de nombres
+
Los ajustes nativos de cada exportador viven bajo extensions.<backend> a nivel de perfil o de asignación, manteniendo el esquema central neutral de radio.
+
+
+
+
+
+
+
+
Cómo fluyen los datos
+
+ Los datos de RF públicos y las capas alimentan una compilación basada en perfiles; exporta un archivo o programa una radio compatible.
+
Clona codeplugger y usa uv. La instalación trae sus propios datos de RF, así que un caso de uso no necesita nada más; para tus propios perfiles conviene tener ssrf-lite y un repositorio de perfiles clonados al lado.
+
+
+
Programar una radio nueva
+
# qué es compatible, y qué puede ir en cada radio
+uv run codeplugger radios
+uv run codeplugger use-cases
+
+# compilar, sin tocar el hardware
+uv run codeplugger build \
+ --radio baofeng_bf888s --use-case gmrs
+
+# o los canales de un grupo comunitario, por nombre
+uv run codeplugger build \
+ --radio baofeng_bf888s --profile chioff
+
+# luego escribirlo, con la confirmación del backend
+uv run codeplugger program \
+ --radio baofeng_bf888s --use-case gmrs \
+ --port /dev/cu.usbserial-10 --confirm
- Prebuilt and validated by CI from public RF data and public profiles.
- Download the file for your radio and import it — no clone, no
- install. These are build artifacts: they are regenerated from source
- on every change, so program your radio from a fresh download rather
- than editing one.
+ {s["lead"]}
{"".join(sections)}
@@ -434,7 +531,13 @@ def main() -> int:
(args.out / "index.json").write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
- (args.out / "index.html").write_text(render_page(manifest), encoding="utf-8")
+ # --out is the English codeplugs directory; the other languages' pages
+ # sit beside it at //codeplugs/ and link back into it.
+ site_root = args.out.parent
+ for lang, page in PAGES.items():
+ page_dir = args.out if lang == "en" else site_root / page
+ page_dir.mkdir(parents=True, exist_ok=True)
+ (page_dir / "index.html").write_text(render_page(manifest, lang), encoding="utf-8")
for source in manifest["sources"]:
for entry in source["profiles"]:
diff --git a/tools/gen_radio_table.py b/tools/gen_radio_table.py
index b7029e1..2dec2c4 100644
--- a/tools/gen_radio_table.py
+++ b/tools/gen_radio_table.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Generate the 'Supported radios' table in site/index.html.
+"""Generate the 'Supported radios' table in site/index.html and site/es/index.html.
The row set is authoritative from ``radios/*/capabilities.json``. Presentation
detail that capabilities.json deliberately does not model (vendor blurb, 'hw'
@@ -35,13 +35,43 @@
INDEX_HTML = SITE_DIR / "index.html"
RADIOS_JSON = SITE_DIR / "radios.json"
+# Every landing page carrying the table, by language. Each is hand-written
+# copy around the same generated tbody; only the handful of strings the
+# generator itself emits are translated here.
+PAGES = {
+ "en": INDEX_HTML,
+ "es": SITE_DIR / "es" / "index.html",
+}
+
+STRINGS = {
+ "en": {
+ "hw": "Verified on a physical radio",
+ "supported": "Supported",
+ "not_supported": "Not supported",
+ "codeplug": "Codeplug",
+ "codeplugs": "{n} codeplugs",
+ },
+ "es": {
+ "hw": "Verificado en una radio física",
+ "supported": "Compatible",
+ "not_supported": "No compatible",
+ "codeplug": "Codeplug",
+ "codeplugs": "{n} codeplugs",
+ },
+}
+
BEGIN_MARKER = ""
END_MARKER = ""
ROW_INDENT = " " * 14
CELL_INDENT = " " * 16
-HW_BADGE = 'hw'
+
+def hw_badge(lang: str = "en") -> str:
+ return f'hw'
+
+
+HW_BADGE = hw_badge("en")
# Backends that a capabilities block implies. If a radio declares one of these
# blocks it is the backend, and site/radios.json is not allowed to disagree.
@@ -206,7 +236,8 @@ def normalize_cell(value, *, where: str) -> dict:
raise GenError(f"{where}: cell must be a bool or an object, got {type(value).__name__}")
-def render_cell(cell: dict) -> str:
+def render_cell(cell: dict, lang: str = "en") -> str:
+ s = STRINGS[lang]
supported = cell["supported"]
cls = "yes" if supported else "no"
glyph = "✓" if supported else "—"
@@ -215,28 +246,32 @@ def render_cell(cell: dict) -> str:
if cell["note"]:
body += f' {cell["note"]}'
if cell["hw"]:
- body += HW_BADGE
+ body += hw_badge(lang)
if not cell["note"] and not cell["hw"]:
- label = "Supported" if supported else "Not supported"
+ label = s["supported"] if supported else s["not_supported"]
body += f'{label}'
return f'
{body}
'
-def render_codeplug_link(entries: list[dict]) -> str:
+def render_codeplug_link(entries: list[dict], lang: str = "en") -> str:
"""Link a radio to the codeplugs built for it on the downloads page.
The anchor is the profile ID, which is the ``id`` the downloads page gives
each card. Several codeplugs land on the first one; they sit together in
- the same source section, so the rest are a scroll away.
+ the same source section, so the rest are a scroll away. The href is
+ relative, so from ``es/index.html`` it lands on the Spanish downloads page.
"""
+ s = STRINGS[lang]
anchor = entries[0]["profile_id"]
- label = "Codeplug" if len(entries) == 1 else f"{len(entries)} codeplugs"
+ label = s["codeplug"] if len(entries) == 1 else s["codeplugs"].format(n=len(entries))
return f'{label}'
-def render_row(cap: dict, row: dict, codeplugs: dict[str, list[dict]]) -> list[str]:
+def render_row(
+ cap: dict, row: dict, codeplugs: dict[str, list[dict]], lang: str = "en"
+) -> list[str]:
radio_id = cap["id"]
vendor = row.get("vendor")
if not vendor:
@@ -283,15 +318,15 @@ def render_row(cap: dict, row: dict, codeplugs: dict[str, list[dict]]) -> list[s
name = cap["name"]
entries = codeplugs.get(radio_id)
- download = f" {render_codeplug_link(entries)}" if entries else ""
+ download = f" {render_codeplug_link(entries, lang)}" if entries else ""
lines = [
f"{ROW_INDENT}
",
f'{CELL_INDENT}
{name} '
f'{vendor}{download}
',
f'{CELL_INDENT}
{derive_modes(cap)}
',
]
- lines += [CELL_INDENT + render_cell(cells[c]) for c in ARTIFACT_COLUMNS]
- lines.append(CELL_INDENT + render_cell(headless))
+ lines += [CELL_INDENT + render_cell(cells[c], lang) for c in ARTIFACT_COLUMNS]
+ lines.append(CELL_INDENT + render_cell(headless, lang))
lines.append(f"{ROW_INDENT}
")
return lines
@@ -301,7 +336,7 @@ def render_row(cap: dict, row: dict, codeplugs: dict[str, list[dict]]) -> list[s
# --------------------------------------------------------------------------
-def build_tbody(codeplugs: dict[str, list[dict]] | None = None) -> str:
+def build_tbody(codeplugs: dict[str, list[dict]] | None = None, lang: str = "en") -> str:
caps = load_capabilities()
rows = load_rows()
codeplugs = codeplugs or {}
@@ -339,21 +374,23 @@ def build_tbody(codeplugs: dict[str, list[dict]] | None = None) -> str:
lines: list[str] = []
for row in rows:
- lines += render_row(caps[row["id"]], row, codeplugs)
+ lines += render_row(caps[row["id"]], row, codeplugs, lang)
return "\n".join(lines)
-def render_index(current: str, codeplugs: dict[str, list[dict]] | None = None) -> str:
+def render_index(
+ current: str, codeplugs: dict[str, list[dict]] | None = None, lang: str = "en"
+) -> str:
try:
head, rest = current.split(BEGIN_MARKER, 1)
_, tail = rest.split(END_MARKER, 1)
except ValueError as exc:
raise GenError(
- f"{INDEX_HTML} is missing the {BEGIN_MARKER} / {END_MARKER} markers"
+ f"{PAGES[lang]} is missing the {BEGIN_MARKER} / {END_MARKER} markers"
) from exc
return (
- f"{head}{BEGIN_MARKER}\n{build_tbody(codeplugs)}\n{ROW_INDENT}{END_MARKER}{tail}"
+ f"{head}{BEGIN_MARKER}\n{build_tbody(codeplugs, lang)}\n{ROW_INDENT}{END_MARKER}{tail}"
)
@@ -377,25 +414,34 @@ def main(argv: list[str] | None = None) -> int:
parser.error("--check compares against the committed table, which has no links")
codeplugs = load_codeplugs(args.codeplugs) if args.codeplugs else None
- current = INDEX_HTML.read_text()
- updated = render_index(current, codeplugs)
+ stale = []
+ rewrote = []
+ for lang, path in PAGES.items():
+ current = path.read_text()
+ updated = render_index(current, codeplugs, lang)
+ if current == updated:
+ continue
+ rel = path.relative_to(REPO_ROOT)
+ if args.check:
+ stale.append(str(rel))
+ else:
+ path.write_text(updated)
+ rewrote.append(str(rel))
if args.check:
- if current != updated:
+ if stale:
print(
- "site/index.html is stale -- run: python tools/gen_radio_table.py",
+ f"{', '.join(stale)} stale -- run: python tools/gen_radio_table.py",
file=sys.stderr,
)
return 1
- print("site/index.html radio table is up to date")
+ print("site radio tables are up to date")
return 0
- if current == updated:
- print("site/index.html radio table already up to date")
+ if not rewrote:
+ print("site radio tables already up to date")
return 0
-
- INDEX_HTML.write_text(updated)
- print(f"rewrote radio table in {INDEX_HTML.relative_to(REPO_ROOT)}")
+ print(f"rewrote radio table in {', '.join(rewrote)}")
return 0