Add the Panama Home Assistant bridge

This commit is contained in:
Gabriel Brown
2026-08-17 13:27:11 -04:00
parent 3a903934c0
commit 173c00fca7
3 changed files with 651 additions and 0 deletions
+434
View File
@@ -0,0 +1,434 @@
#!/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
from typing import Any
ENV_KEYS = (
"PANAMA_HOME_ASSISTANT_URL",
"PANAMA_HOME_ASSISTANT_TOKEN",
"PANAMA_HOME_ASSISTANT_ENTITIES",
)
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
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"
PANAMA_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/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 and self.entity_ids)
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:
private_env = read_panama_env()
private_env.update(dict(os.environ if env is None else 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: dict[str, str] | 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 = ""
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_entities(
raw: list[dict[str, Any]],
configured: Sequence[str],
) -> list[dict[str, object]]:
by_id = {
str(item.get("entity_id", "")): item
for item in raw
if isinstance(item, dict)
}
result: list[dict[str, object]] = []
for entity_id in configured:
item = by_id.get(entity_id)
if not item:
continue
attributes = item.get("attributes")
if not isinstance(attributes, dict):
continue
state = str(item.get("state", "unavailable"))
available = state not in {"unknown", "unavailable"}
friendly_name = attributes.get("friendly_name")
name = (
friendly_name.strip()
if isinstance(friendly_name, str) and friendly_name.strip()
else fallback_name(entity_id)
)
result.append(
{
"id": entity_id,
"name": name,
"domain": entity_id.split(".", 1)[0],
"state": state,
"available": available,
"active": available
and state not in {"off", "closed", "idle", "standby"},
}
)
return result
def ensure_configured(entity_id: str, configured: Sequence[str]) -> str:
if entity_id not in configured:
raise ValueError("entity-not-configured")
return entity_id
def collect_snapshot(config: Config) -> dict[str, object]:
if not config.configured:
return {
"ok": False,
"configured": False,
"generatedAt": int(time.time()),
"entities": [],
"error": "not-configured",
}
try:
raw = request_json(config, "GET", "/api/states")
if not isinstance(raw, list):
raise BridgeError("invalid-response")
entities = normalize_entities(raw, config.entity_ids)
except BridgeError as error:
return {
"ok": False,
"configured": True,
"generatedAt": int(time.time()),
"entities": [],
"error": str(error),
}
return {
"ok": True,
"configured": True,
"generatedAt": int(time.time()),
"entities": entities,
"error": "",
}
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_configured(entity_id, config.entity_ids)
request_json(
config,
"POST",
"/api/services/homeassistant/toggle",
{"entity_id": entity_id},
)
return {"ok": True, "entityId": entity_id, "error": ""}
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 == "snapshot" and len(argv) == 1:
result = collect_snapshot(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 == "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:]))
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'Home Assistant helper contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
probe="$($helper probe)"
jq -e '
.configured == true and
.reachable == true and
(.entityCount | type == "number" and . > 0) and
.error == ""
' <<<"$probe" >/dev/null || fail 'live API probe failed'
snapshot="$($helper snapshot)"
jq -e --argjson expected "$(jq '.entityCount' <<<"$probe")" '
.ok == true and
.configured == true and
.error == "" and
(.generatedAt | type == "number") and
(.entities | type == "array" and length == $expected) and
([.entities[] | (keys | sort) == (["active", "available", "domain", "id", "name", "state"] | sort)] | all) and
([.entities[] | (.active | type == "boolean") and (.available | type == "boolean")] | all)
' <<<"$snapshot" >/dev/null || fail 'live snapshot shape is invalid'
printf 'Home Assistant helper contract: PASS (configured=true, %s favourites; contents redacted)\n' \
"$(jq '.entities | length' <<<"$snapshot")"
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.machinery
import importlib.util
import json
import pathlib
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = pathlib.Path(__file__).resolve().parents[2]
HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant"
loader = importlib.machinery.SourceFileLoader("panama_home_assistant", str(HELPER))
spec = importlib.util.spec_from_loader(loader.name, loader)
bridge = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = bridge
loader.exec_module(bridge)
class FakeHomeAssistant(BaseHTTPRequestHandler):
requests: list[dict[str, object]] = []
def log_message(self, _format: str, *args: object) -> None:
del args
def _record(self, body: bytes = b"") -> None:
self.requests.append(
{
"method": self.command,
"path": self.path,
"authorization": self.headers.get("Authorization"),
"body": body,
}
)
def _json(self, value: object, status: int = 200) -> None:
payload = json.dumps(value).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self) -> None:
self._record()
if self.path == "/api/":
self._json({"message": "API running."})
return
if self.path == "/api/states":
self._json(
[
{
"entity_id": "light.hall",
"state": "off",
"attributes": {"friendly_name": "Hall"},
},
{
"entity_id": "sensor.private",
"state": "1",
"attributes": {"friendly_name": "Private"},
},
{
"entity_id": "light.kitchen",
"state": "on",
"attributes": {"friendly_name": "Kitchen"},
},
]
)
return
self._json({"message": "not found"}, 404)
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
self._record(body)
if self.path == "/api/services/homeassistant/toggle":
self._json([])
return
self._json({"message": "not found"}, 404)
class HomeAssistantBridgeTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeHomeAssistant)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.base_url = f"http://127.0.0.1:{cls.server.server_port}"
@classmethod
def tearDownClass(cls) -> None:
cls.server.shutdown()
cls.server.server_close()
cls.thread.join(timeout=2)
def setUp(self) -> None:
FakeHomeAssistant.requests.clear()
def config(self) -> object:
return bridge.Config(
base_url=self.base_url,
token="fixture-token",
entity_ids=("light.kitchen", "light.hall"),
)
def test_environment_configuration_wins_over_legacy_sources(self) -> None:
env = {
"PANAMA_HOME_ASSISTANT_URL": "https://home.example",
"PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token",
"PANAMA_HOME_ASSISTANT_ENTITIES": "light.kitchen,light.hall",
}
config = bridge.resolve_config(
env,
legacy=lambda: (_ for _ in ()).throw(AssertionError("legacy called")),
)
self.assertEqual(config.base_url, "https://home.example")
self.assertEqual(config.entity_ids, ("light.kitchen", "light.hall"))
def test_snapshot_filters_and_preserves_configured_order(self) -> None:
snapshot = bridge.collect_snapshot(self.config())
self.assertTrue(snapshot["ok"])
self.assertEqual(
[item["name"] for item in snapshot["entities"]],
["Kitchen", "Hall"],
)
self.assertNotIn("sensor.private", json.dumps(snapshot))
def test_snapshot_uses_bearer_authentication(self) -> None:
bridge.collect_snapshot(self.config())
request = FakeHomeAssistant.requests[-1]
self.assertEqual(request["method"], "GET")
self.assertEqual(request["path"], "/api/states")
self.assertEqual(request["authorization"], "Bearer fixture-token")
def test_toggle_rejects_an_unconfigured_entity(self) -> None:
with self.assertRaisesRegex(ValueError, "entity-not-configured"):
bridge.ensure_configured(
"light.office",
("light.kitchen",),
)
def test_toggle_calls_the_homeassistant_service(self) -> None:
result = bridge.toggle(self.config(), "light.kitchen")
self.assertTrue(result["ok"])
request = FakeHomeAssistant.requests[-1]
self.assertEqual(request["method"], "POST")
self.assertEqual(request["path"], "/api/services/homeassistant/toggle")
self.assertEqual(
json.loads(request["body"]),
{"entity_id": "light.kitchen"},
)
def test_authentication_error_is_redacted(self) -> None:
self.assertEqual(
bridge.public_http_error(401, "sensitive response"),
"authentication-required",
)
self.assertEqual(
bridge.public_http_error(500, "sensitive response"),
"request-failed",
)
def test_gsettings_array_parser_handles_empty_and_values(self) -> None:
self.assertEqual(bridge.parse_gsettings_array("@as []"), ())
self.assertEqual(
bridge.parse_gsettings_array("['light.kitchen', 'light.hall']"),
("light.kitchen", "light.hall"),
)
if __name__ == "__main__":
unittest.main()