Add the Panama Home Assistant bridge
This commit is contained in:
+35
@@ -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()
|
||||
Reference in New Issue
Block a user