SmartestHome/tools/config-export.py

202 lines
10 KiB
Python
Executable File

#!/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://<container_host_ip>:<ports.identity>`, computed here, never typed anywhere.
Usage:
config-export.py <config.json> # core values only
config-export.py <config.json> --kiosk <hostname> # + that kiosk's values
config-export.py <config.json> --audio-endpoint <hostname> # + 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 <config.json> [--kiosk <hostname>]", 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", "opnsense_api_key", "opnsense_api_secret",
"ssh_authorized_key", "kiosk_password", "admin_password_hash"):
emit(f"CORE_{key.upper()}", secrets.get(key, ""))
# --- The existing OPNsense firewall (digest-engine's network digest reads its IDS) ---
# Emitted as scalars rather than as a JSON blob because the ISO builder writes them
# into IDSconf.json field by field, the same way it writes every generated env file:
# one place holds the values, and nothing downstream re-parses a nested structure.
opnsense = cfg.get("opnsense", {}) or {}
emit("CORE_OPNSENSE_BASE_URL", str(opnsense.get("base_url", "")).rstrip("/"))
emit("CORE_OPNSENSE_VERIFY_TLS", opnsense.get("verify_tls", True))
# Space-separated for shell iteration; the builder turns it back into a JSON array.
emit("CORE_OPNSENSE_INTERFACES", " ".join(str(i) for i in opnsense.get("interfaces", []) or []))
emit("CORE_OPNSENSE_MAX_ALERTS_SCANNED", opnsense.get("max_alerts_scanned", 5000))
emit("CORE_OPNSENSE_TOP_SIGNATURES", opnsense.get("top_signatures", 8))
emit("CORE_OPNSENSE_TOP_HOSTS", opnsense.get("top_hosts", 5))
emit("CORE_OPNSENSE_PACKET_CAPTURE_REFERENCE", opnsense.get("packet_capture_reference", ""))
# --- 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)
proxy = cfg.get("proxy", {}) or {}
emit("CORE_PROXY_ENABLED", proxy.get("enabled", False))
emit("CORE_PROXY_HOSTNAME", proxy.get("hostname", ""))
emit("CORE_PROXY_TLS", proxy.get("tls", "internal"))
emit("CORE_PROXY_CERT_FILE", proxy.get("cert_file", ""))
emit("CORE_PROXY_KEY_FILE", proxy.get("key_file", ""))
# The admin panel's own URL, derived like every other one. Through the proxy when
# there is one, straight at identity-web when there isn't — so the printed URL is
# always the one that actually works.
if proxy.get("enabled") and proxy.get("hostname"):
base = f"https://{proxy['hostname']}"
emit("CORE_ADMIN_URL", f"{base}/admin.html?api={base}/api/identity")
emit("CORE_PROXY_BASE_URL", base)
else:
emit("CORE_ADMIN_URL",
f"http://{container_ip}:{ports['identity_web']}/admin.html"
f"?api=http://{container_ip}:{ports['identity']}")
emit("CORE_PROXY_BASE_URL", "")
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"))
emit("CORE_ARM64_PREBAKE", cfg.get("build", {}).get("arm64_prebake", True))
# --- 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))