#!/usr/bin/env python3 """Turn CoreSystemConfig.json into shell variable assignments, deriving everything derivable along the way. Consumed by tools/lib/coreconfig.sh via `eval`. THIS IS WHERE THE TWINNING ACTUALLY HAPPENS. The config states two numbers — the container host's last octet and the LLM host's — and this script computes every address and every service URL from them. So the container host's OLLAMA_HOST is the LLM host's address *by construction*: change `llm_host.ip_last_octet` from 13 to 21 and the container-host ISO's Ollama URL follows on the next build, with nothing to keep in sync by hand and nothing that can drift. The same applies to every kiosk: a door panel's IDENTITY_URL is `http://:`, computed here, never typed anywhere. Usage: config-export.py # core values only config-export.py --kiosk # + that kiosk's values config-export.py --audio-endpoint # + that endpoint's values """ from __future__ import annotations import json import shlex import sys from pathlib import Path def emit(name: str, value) -> None: if isinstance(value, bool): value = "true" if value else "false" print(f"{name}={shlex.quote(str(value))}") def main(argv: list[str]) -> int: if len(argv) < 2: print("usage: config-export.py [--kiosk ]", file=sys.stderr) return 2 cfg = json.loads(Path(argv[1]).read_text()) kiosk_hostname = None if "--kiosk" in argv: kiosk_hostname = argv[argv.index("--kiosk") + 1] audio_hostname = None if "--audio-endpoint" in argv: audio_hostname = argv[argv.index("--audio-endpoint") + 1] prefix = cfg["network"]["subnet_prefix"] ports = {k: v for k, v in cfg["ports"].items() if not k.startswith("_")} container_ip = f"{prefix}.{cfg['container_host']['ip_last_octet']}" llm_ip = f"{prefix}.{cfg['llm_host']['ip_last_octet']}" gateway = f"{prefix}.{cfg['network']['gateway_last_octet']}" # --- Household / image basics --- emit("CORE_TIMEZONE", cfg["household"]["timezone"]) emit("CORE_LOCALE", cfg["household"]["locale"]) emit("CORE_KEYBOARD_LAYOUT", cfg["household"]["keyboard_layout"]) emit("CORE_DEBIAN_RELEASE", cfg["household"]["debian_release"]) # --- Network --- emit("CORE_SUBNET_PREFIX", prefix) emit("CORE_NETMASK", cfg["network"]["netmask"]) emit("CORE_GATEWAY", gateway) emit("CORE_DNS_SERVERS", " ".join(cfg["network"]["dns_servers"])) emit("CORE_WIFI_SSID", cfg["network"]["wifi"].get("ssid", "")) emit("CORE_WIFI_PSK", cfg["network"]["wifi"].get("psk", "")) # --- The two core hosts, each aware of the other. This is the twinning. --- emit("CORE_CONTAINER_HOST_IP", container_ip) emit("CORE_CONTAINER_HOST_NAME", cfg["container_host"]["hostname"]) emit("CORE_CONTAINER_HOST_DISK", cfg["container_host"]["install_disk"]) emit("CORE_CONTAINER_HOST_USER", cfg["container_host"]["admin_username"]) emit("CORE_LLM_HOST_IP", llm_ip) emit("CORE_LLM_HOST_NAME", cfg["llm_host"]["hostname"]) emit("CORE_LLM_HOST_DISK", cfg["llm_host"]["install_disk"]) emit("CORE_LLM_HOST_USER", cfg["llm_host"]["admin_username"]) # --- LLM host settings --- llm = cfg["llm_host"] emit("CORE_LLM_TIER", llm["tier"]) emit("CORE_LLM_TEXT_MODEL_GPU", llm["text_model_gpu"]) emit("CORE_LLM_TEXT_MODEL_CPU", llm["text_model_cpu"]) emit("CORE_LLM_VISION_MODEL", llm["vision_model"]) emit("CORE_LLM_PULL_VISION_MODEL", llm.get("pull_vision_model", True)) emit("CORE_LLM_KEEP_ALIVE", llm["keep_alive"]) emit("CORE_LLM_MAX_LOADED_MODELS", llm["max_loaded_models"]) emit("CORE_LLM_NUM_PARALLEL", llm["num_parallel"]) # --- Ports, individually and as derived URLs --- for name, port in sorted(ports.items()): emit(f"CORE_PORT_{name.upper()}", port) # DERIVED URLS — the whole reason this file exists. Nothing below is ever written # by hand in a build script or an env file; every one is computed from an address # and a port that each appear exactly once in CoreSystemConfig.json. emit("CORE_HA_URL", f"http://{container_ip}:{ports['home_assistant']}") emit("CORE_MQTT_BROKER_HOST", container_ip) emit("CORE_MQTT_BROKER_PORT", ports["mqtt"]) emit("CORE_IDENTITY_URL", f"http://{container_ip}:{ports['identity']}") emit("CORE_IDENTITY_WEB_URL", f"http://{container_ip}:{ports['identity_web']}") emit("CORE_PANTRY_VISION_URL", f"http://{container_ip}:{ports['pantry_vision']}") emit("CORE_PANTRY_WEB_URL", f"http://{container_ip}:{ports['pantry_web']}") emit("CORE_DIGEST_WEB_URL", f"http://{container_ip}:{ports['digest_web']}") emit("CORE_ADMIN_WEB_URL", f"http://{container_ip}:{ports['admin_web']}") emit("CORE_TRANSIT_URL", f"http://{container_ip}:{ports['transit']}") emit("CORE_OTP_URL", f"http://{container_ip}:{ports['otp']}") emit("CORE_NTFY_URL", f"http://{container_ip}:{ports['ntfy']}") emit("CORE_GALLERY_SMB_HOST", container_ip) emit("CORE_FRIGATE_URL", f"http://{container_ip}:{ports['frigate']}") emit("CORE_GROCY_URL", f"http://{container_ip}:{ports['grocy']}") # The one that points the OTHER way — the container host's services reaching the # LLM host. Derived from llm_host.ip_last_octet, so the pair can never disagree. emit("CORE_OLLAMA_HOST", f"http://{llm_ip}:{ports['ollama']}") # --- Secrets --- secrets = cfg.get("secrets", {}) for key in ("identity_token", "pantry_vision_token", "transit_token", "mqtt_username", "mqtt_password", "ha_token", "ssh_authorized_key", "kiosk_password", "admin_password_hash"): emit(f"CORE_{key.upper()}", secrets.get(key, "")) # --- Enable flags --- for flag, value in (cfg.get("container_host", {}).get("enable", {}) or {}).items(): if not flag.startswith("_"): emit(f"CORE_ENABLE_{flag.upper()}", value) emit("CORE_VOICE_WAKE_WORD", cfg.get("voice", {}).get("wake_word", "ok_nabu")) emit("CORE_BUILD_OUTPUT_DIR", cfg.get("build", {}).get("output_dir", "iso-out")) # --- The selected kiosk, if one was asked for --- if kiosk_hostname: matches = [k for k in cfg.get("kiosks", []) if k.get("hostname") == kiosk_hostname] if not matches: available = ", ".join(k.get("hostname", "?") for k in cfg.get("kiosks", [])) print(f"echo 'error: no kiosk with hostname {kiosk_hostname!r} in the config " f"(have: {available})' >&2; return 1 2>/dev/null || exit 1") return 1 kiosk = matches[0] emit("CORE_KIOSK_TYPE", kiosk["type"]) emit("CORE_KIOSK_HOSTNAME", kiosk["hostname"]) emit("CORE_KIOSK_FRIENDLY_NAME", kiosk["friendly_name"]) emit("CORE_KIOSK_USERNAME", kiosk["kiosk_username"]) emit("CORE_KIOSK_VOICE_SATELLITE", kiosk.get("voice_satellite", False)) emit("CORE_KIOSK_ENABLE_INSTALLER", kiosk.get("enable_installer", False)) emit("CORE_KIOSK_ENABLE_STEAM_LINK", kiosk.get("enable_steam_link", False)) emit("CORE_KIOSK_ENABLE_GESTURE_CONTROL", kiosk.get("enable_gesture_control", False)) # A per-kiosk wake word overrides the household default. emit("CORE_KIOSK_WAKE_WORD", kiosk.get("wake_word", cfg.get("voice", {}).get("wake_word", "ok_nabu"))) if audio_hostname: matches = [a for a in cfg.get("audio_endpoints", []) if a.get("hostname") == audio_hostname] if not matches: available = ", ".join(a.get("hostname", "?") for a in cfg.get("audio_endpoints", [])) print(f"echo 'error: no audio endpoint with hostname {audio_hostname!r} in the config " f"(have: {available})' >&2; return 1 2>/dev/null || exit 1") return 1 endpoint = matches[0] emit("CORE_AUDIO_HOSTNAME", endpoint["hostname"]) emit("CORE_AUDIO_FRIENDLY_NAME", endpoint["friendly_name"]) emit("CORE_AUDIO_ARCH", endpoint["arch"]) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))