Files
Gabriel Brown 1f694b00b6 Keep .bashrc a bootstrap, and secrets out of the checkout for real
.bashrc is back to its one job: export the Panama paths and source
what it finds. The personal-env block moves into config/bash/shell —
first, because the tmux guard below it reads that file — and the
cargo source that was duplicated between the two files lives only in
shell now.

The real fix is behind that tidying: both Home Assistant helpers
defaulted to the IN-REPO config/bash/env, and the writer rebuilt it
with only its own three lines — which read as "my env vars vanished"
to the person who thought that file was hand-maintained. Both now
prefer ~/.config/panama/env, the migrated home outside the checkout,
with the repo path kept only as a read fallback for unmigrated
machines. The stale legacy copy on this machine is retired; the
working credentials were merged into the migrated file first, after
probing both sets against the live Home Assistant.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 08:49:40 -04:00

566 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""Private Home Assistant REST boundary for the Panama Control Center."""
from __future__ import annotations
import ast
import json
import os
import pathlib
import re
import shlex
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
ENV_KEYS = (
"PANAMA_HOME_ASSISTANT_URL",
"PANAMA_HOME_ASSISTANT_TOKEN",
"PANAMA_HOME_ASSISTANT_ENTITIES",
)
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
# One template renders the whole area list, so a room grouping costs a single
# request. Home Assistant answers /api/template with the rendered text, which
# `tojson` makes a JSON document the caller can parse like any other response.
AREAS_TEMPLATE = (
"{% set ns = namespace(items=[]) %}"
"{% for area in areas() %}"
"{% set ns.items = ns.items + ["
"{'id': area, 'name': area_name(area), 'entities': area_entities(area)}"
"] %}"
"{% endfor %}"
"{{ ns.items | tojson }}"
)
GNOME_EXTENSION = (
pathlib.Path.home()
/ ".local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github"
)
GNOME_SCHEMA_DIR = GNOME_EXTENSION / "schemas"
GNOME_SCHEMA = "org.gnome.shell.extensions.hass-data"
# The migrated personal env (outside the checkout) wins; the in-repo file is
# only read on machines that have not migrated. See panama-home-assistant-config.
LEGACY_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
_config_home = pathlib.Path(os.environ.get("XDG_CONFIG_HOME", str(pathlib.Path.home() / ".config")))
_migrated_env = _config_home / "panama" / "env"
PANAMA_ENV = _migrated_env if _migrated_env.exists() else LEGACY_ENV
Runner = Callable[..., subprocess.CompletedProcess[str]]
class BridgeError(RuntimeError):
"""An error code safe to pass across the shell boundary."""
@dataclass(frozen=True)
class Config:
base_url: str
token: str
entity_ids: tuple[str, ...]
@property
def configured(self) -> bool:
return bool(self.base_url and self.token)
def compact_json(value: dict[str, object]) -> str:
return json.dumps(value, separators=(",", ":"))
def read_panama_env(path: pathlib.Path = PANAMA_ENV) -> dict[str, str]:
values: dict[str, str] = {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return values
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("export "):
stripped = stripped[7:].lstrip()
if "=" not in stripped:
continue
key, raw_value = stripped.split("=", 1)
key = key.strip()
if key not in ENV_KEYS:
continue
try:
parsed = shlex.split(raw_value, comments=True, posix=True)
except ValueError:
continue
if len(parsed) == 1:
values[key] = parsed[0]
return values
def normalize_url(value: str) -> str:
value = value.strip().rstrip("/")
if not value:
return ""
parsed = urllib.parse.urlsplit(value)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username
or parsed.password
):
raise ValueError("invalid-url")
return value
def parse_entity_ids(value: str | Sequence[str]) -> tuple[str, ...]:
candidates = value.split(",") if isinstance(value, str) else value
result: list[str] = []
for candidate in candidates:
entity_id = str(candidate).strip()
if not entity_id:
continue
if not ENTITY_ID.fullmatch(entity_id):
raise ValueError("invalid-entity")
if entity_id not in result:
result.append(entity_id)
return tuple(result)
def parse_gsettings_string(output: str) -> str:
try:
value = ast.literal_eval(output.strip())
except (SyntaxError, ValueError):
return ""
return value if isinstance(value, str) else ""
def parse_gsettings_array(output: str) -> tuple[str, ...]:
stripped = output.strip()
if stripped.startswith("@as "):
stripped = stripped[4:].strip()
try:
value = ast.literal_eval(stripped)
except (SyntaxError, ValueError):
return ()
if not isinstance(value, (list, tuple)):
return ()
return tuple(item for item in value if isinstance(item, str))
def run_command(
command: list[str],
*,
runner: Runner = subprocess.run,
timeout: float = 5,
) -> subprocess.CompletedProcess[str]:
return runner(
command,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def gsettings_value(key: str, runner: Runner = subprocess.run) -> str:
if not GNOME_SCHEMA_DIR.is_dir():
return ""
try:
result = run_command(
[
"gsettings",
"--schemadir",
str(GNOME_SCHEMA_DIR),
"get",
GNOME_SCHEMA,
key,
],
runner=runner,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
return result.stdout.strip() if result.returncode == 0 else ""
def secret_service_token(runner: Runner = subprocess.run) -> str:
try:
result = run_command(
["secret-tool", "lookup", "token_string", "user_token"],
runner=runner,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
return result.stdout.strip() if result.returncode == 0 else ""
def load_legacy_config(runner: Runner = subprocess.run) -> Config:
base_url = parse_gsettings_string(gsettings_value("hass-url", runner))
entity_ids = parse_gsettings_array(
gsettings_value("hass-enabled-entities", runner)
)
token = secret_service_token(runner)
return Config(base_url=base_url, token=token, entity_ids=entity_ids)
def resolve_config(
env: Mapping[str, str] | None = None,
legacy: Callable[[], Config] = load_legacy_config,
) -> Config:
# An explicitly supplied environment is the WHOLE environment. Reading the
# user's private env file underneath it makes callers -- tests especially --
# depend on whatever happens to be in that file: adding a real
# PANAMA_HOME_ASSISTANT_ENTITIES to it silently overrode a fixture that was
# asserting the legacy fallback. Production passes env=None and still gets
# the file.
if env is None:
private_env = read_panama_env()
private_env.update(dict(os.environ))
else:
private_env = dict(env)
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
entities_value = private_env.get("PANAMA_HOME_ASSISTANT_ENTITIES", "").strip()
legacy_config = Config("", "", ())
if not (url_value and token_value and entities_value):
try:
legacy_config = legacy()
except (OSError, subprocess.SubprocessError, ValueError):
legacy_config = Config("", "", ())
base_url = normalize_url(url_value or legacy_config.base_url)
token = token_value or legacy_config.token
entity_ids = (
parse_entity_ids(entities_value)
if entities_value
else parse_entity_ids(legacy_config.entity_ids)
)
return Config(base_url=base_url, token=token, entity_ids=entity_ids)
def public_http_error(status: int, body: str) -> str:
del body
return "authentication-required" if status in {401, 403} else "request-failed"
def request_json(
config: Config,
method: str,
path: str,
payload: Mapping[str, object] | None = None,
) -> object:
if not config.base_url or not config.token:
raise BridgeError("not-configured")
body = None
if payload is not None:
body = json.dumps(payload, separators=(",", ":")).encode()
request = urllib.request.Request(
config.base_url + path,
data=body,
method=method,
headers={
"Authorization": f"Bearer {config.token}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=5) as response:
response_body = response.read()
except urllib.error.HTTPError as error:
try:
error_body = error.read().decode(errors="replace")
except OSError:
error_body = ""
finally:
error.close()
raise BridgeError(public_http_error(error.code, error_body)) from None
except (TimeoutError, urllib.error.URLError, OSError):
raise BridgeError("unreachable") from None
if not response_body:
return None
try:
return json.loads(response_body)
except (json.JSONDecodeError, UnicodeDecodeError):
raise BridgeError("invalid-response") from None
def fallback_name(entity_id: str) -> str:
return entity_id.split(".", 1)[1].replace("_", " ").title()
def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
result: list[dict[str, object]] = []
for item in raw:
if not isinstance(item, dict):
continue
entity_id = item.get("entity_id")
attributes = item.get("attributes")
if not isinstance(entity_id, str) or not entity_id.startswith("light."):
continue
if not ENTITY_ID.fullmatch(entity_id) or not isinstance(attributes, dict):
continue
state = str(item.get("state", "unavailable"))
available = state not in {"unknown", "unavailable"}
active = available and state == "on"
raw_brightness = attributes.get("brightness")
brightness_pct = (
round(max(0, min(255, raw_brightness)) * 100 / 255)
if active
and isinstance(raw_brightness, (int, float))
and not isinstance(raw_brightness, bool)
else 0
)
modes = attributes.get("supported_color_modes", [])
dimmable = (
isinstance(modes, list) and any(mode != "onoff" for mode in modes)
) or isinstance(raw_brightness, (int, float))
source_name = attributes.get("friendly_name")
result.append(
{
"id": entity_id,
"sourceName": (
source_name.strip()
if isinstance(source_name, str) and source_name.strip()
else fallback_name(entity_id)
),
"state": state,
"available": available,
"active": active,
"dimmable": dimmable,
"brightnessPct": brightness_pct,
}
)
return result
def collect_catalog(config: Config) -> dict[str, object]:
legacy_entity_ids = list(config.entity_ids)
if not config.configured:
return {
"ok": False,
"configured": False,
"generatedAt": int(time.time()),
"entities": [],
"legacyEntityIds": legacy_entity_ids,
"error": "not-configured",
}
try:
raw = request_json(config, "GET", "/api/states")
if not isinstance(raw, list):
raise BridgeError("invalid-response")
entities = normalize_catalog(raw)
except BridgeError as error:
return {
"ok": False,
"configured": True,
"generatedAt": int(time.time()),
"entities": [],
"legacyEntityIds": legacy_entity_ids,
"error": str(error),
}
return {
"ok": True,
"configured": True,
"generatedAt": int(time.time()),
"entities": entities,
"legacyEntityIds": legacy_entity_ids,
"error": "",
}
def collect_snapshot(config: Config) -> dict[str, object]:
return collect_catalog(config)
def normalize_areas(raw: Sequence[object]) -> list[dict[str, object]]:
# A template renders whatever Home Assistant happens to hold, so every entry
# is checked on its own: one malformed area is dropped rather than costing
# the caller the whole grouping. Only lights are kept, because lights are
# all the catalog holds today, and an area left with none is not a room the
# UI can draw.
result: list[dict[str, object]] = []
for item in raw:
if not isinstance(item, dict):
continue
area_id = item.get("id")
name = item.get("name")
entities = item.get("entities")
if not isinstance(area_id, str) or not isinstance(name, str):
continue
if not area_id.strip() or not name.strip() or not isinstance(entities, list):
continue
lights = [
entity_id
for entity_id in entities
if isinstance(entity_id, str)
and entity_id.startswith("light.")
and ENTITY_ID.fullmatch(entity_id)
]
if not lights:
continue
result.append(
{"id": area_id.strip(), "name": name.strip(), "entities": lights}
)
return result
def collect_areas(config: Config) -> dict[str, object]:
if not config.configured:
return {"ok": False, "areas": [], "error": "not-configured"}
try:
raw = request_json(
config, "POST", "/api/template", {"template": AREAS_TEMPLATE}
)
if not isinstance(raw, list):
raise BridgeError("invalid-response")
areas = normalize_areas(raw)
except BridgeError as error:
return {"ok": False, "areas": [], "error": str(error)}
return {"ok": True, "areas": areas, "error": ""}
def discovered_light_ids(config: Config) -> set[str]:
raw = request_json(config, "GET", "/api/states")
if not isinstance(raw, list):
raise BridgeError("invalid-response")
return {item["id"] for item in normalize_catalog(raw) if isinstance(item["id"], str)}
def ensure_discovered(config: Config, entity_id: str) -> str:
if entity_id not in discovered_light_ids(config):
raise ValueError("entity-not-discovered")
return entity_id
def probe(config: Config) -> dict[str, object]:
if not config.configured:
return {
"configured": False,
"reachable": False,
"entityCount": len(config.entity_ids),
"error": "not-configured",
}
try:
request_json(config, "GET", "/api/")
except BridgeError as error:
return {
"configured": True,
"reachable": False,
"entityCount": len(config.entity_ids),
"error": str(error),
}
return {
"configured": True,
"reachable": True,
"entityCount": len(config.entity_ids),
"error": "",
}
def toggle(config: Config, entity_id: str) -> dict[str, object]:
entity_id = ensure_discovered(config, entity_id)
request_json(
config,
"POST",
"/api/services/homeassistant/toggle",
{"entity_id": entity_id},
)
return {"ok": True, "entityId": entity_id, "error": ""}
def set_brightness(
config: Config, entity_id: str, percent: int
) -> dict[str, object]:
if isinstance(percent, bool) or not isinstance(percent, int) or not 0 <= percent <= 100:
raise ValueError("invalid-brightness")
ensure_discovered(config, entity_id)
if percent == 0:
path = "/api/services/light/turn_off"
payload = {"entity_id": entity_id}
else:
path = "/api/services/light/turn_on"
payload = {"entity_id": entity_id, "brightness_pct": percent}
request_json(config, "POST", path, payload)
return {"ok": True, "entityId": entity_id, "brightnessPct": percent, "error": ""}
def parse_brightness(value: str) -> int:
if not re.fullmatch(r"(?:0|[1-9][0-9]{0,2})", value):
raise ValueError("invalid-brightness")
percent = int(value)
if percent > 100:
raise ValueError("invalid-brightness")
return percent
def open_home(config: Config) -> dict[str, object]:
if not config.base_url:
return {"ok": False, "error": "not-configured"}
try:
subprocess.Popen(
["xdg-open", config.base_url],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError:
return {"ok": False, "error": "open-failed"}
return {"ok": True, "error": ""}
def print_result(value: dict[str, object]) -> None:
sys.stdout.write(compact_json(value) + "\n")
def main(argv: list[str]) -> int:
try:
config = resolve_config()
except ValueError as error:
print_result({"ok": False, "configured": False, "error": str(error)})
return 2
command = argv[0] if argv else "probe"
if command == "probe" and len(argv) <= 1:
result = probe(config)
success = bool(result["reachable"])
elif command in {"catalog", "snapshot"} and len(argv) == 1:
result = collect_catalog(config)
success = bool(result["ok"])
elif command == "areas" and len(argv) == 1:
result = collect_areas(config)
success = bool(result["ok"])
elif command == "toggle" and len(argv) == 2:
try:
result = toggle(config, argv[1])
except ValueError as error:
result = {"ok": False, "error": str(error)}
except BridgeError as error:
result = {"ok": False, "error": str(error)}
success = bool(result["ok"])
elif command == "brightness" and len(argv) == 3:
try:
result = set_brightness(config, argv[1], parse_brightness(argv[2]))
except ValueError as error:
result = {"ok": False, "error": str(error)}
except BridgeError as error:
result = {"ok": False, "error": str(error)}
success = bool(result["ok"])
elif command == "open" and len(argv) == 1:
result = open_home(config)
success = bool(result["ok"])
else:
result = {"ok": False, "error": "invalid-command"}
success = False
print_result(result)
return 0 if success else 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))