#!/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" QML_SERVICE = ROOT / "config/dot/quickshell/services/HomeAssistant.qml" 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.kitchen", "state": "on", "attributes": { "friendly_name": "Kitchen", "brightness": 128, "supported_color_modes": ["brightness"], }, }, { "entity_id": "light.hall", "state": "off", "attributes": { "friendly_name": "Hall", "supported_color_modes": ["color_temp"], }, }, {"entity_id": "sensor.private", "state": "1", "attributes": {"token": "never-return"}}, { "entity_id": "light.malformed", "state": "on", "attributes": "invalid", }, { "entity_id": "light.corner", "state": "unavailable", "attributes": { "friendly_name": "Corner", "supported_color_modes": ["brightness"], }, }, ] ) 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 in { "/api/services/homeassistant/toggle", "/api/services/light/turn_on", "/api/services/light/turn_off", }: 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_config_does_not_require_legacy_entity_ids(self) -> None: self.assertTrue( bridge.Config( base_url="https://home.example", token="fixture-token", entity_ids=(), ).configured ) def test_legacy_entity_ids_remain_migration_metadata(self) -> None: config = bridge.resolve_config( { "PANAMA_HOME_ASSISTANT_URL": "https://home.example", "PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token", }, legacy=lambda: bridge.Config( base_url="https://legacy.example", token="legacy-token", entity_ids=("light.kitchen",), ), ) self.assertEqual(config.base_url, "https://home.example") self.assertEqual(config.token, "fixture-token") self.assertEqual(config.entity_ids, ("light.kitchen",)) def test_catalog_filters_and_normalizes_discovered_lights(self) -> None: catalog = bridge.collect_catalog(self.config()) self.assertTrue(catalog["ok"]) self.assertEqual( [item["sourceName"] for item in catalog["entities"]], ["Kitchen", "Hall", "Corner"], ) self.assertEqual(catalog["entities"][0]["brightnessPct"], 50) self.assertEqual(catalog["entities"][1]["brightnessPct"], 0) self.assertTrue(all(item["dimmable"] for item in catalog["entities"])) rendered = json.dumps(catalog) self.assertNotIn("sensor.private", rendered) self.assertNotIn("light.malformed", rendered) self.assertNotIn("token", rendered) def test_snapshot_uses_bearer_authentication(self) -> None: bridge.collect_catalog(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_snapshot_remains_a_catalog_compatibility_alias(self) -> None: snapshot = bridge.collect_snapshot(self.config()) self.assertTrue(snapshot["ok"]) self.assertEqual( [item["sourceName"] for item in snapshot["entities"]], ["Kitchen", "Hall", "Corner"], ) def test_action_rejects_an_entity_not_in_the_live_catalog(self) -> None: with self.assertRaisesRegex(ValueError, "entity-not-discovered"): bridge.toggle(self.config(), "light.office") def test_toggle_authorizes_against_discovered_catalog(self) -> None: result = bridge.toggle(self.config(), "light.corner") 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.corner"}, ) def test_brightness_uses_turn_on_for_positive_percent(self) -> None: bridge.set_brightness(self.config(), "light.kitchen", 62) request = FakeHomeAssistant.requests[-1] self.assertEqual(request["path"], "/api/services/light/turn_on") self.assertEqual( json.loads(request["body"]), {"entity_id": "light.kitchen", "brightness_pct": 62}, ) def test_brightness_zero_uses_turn_off(self) -> None: bridge.set_brightness(self.config(), "light.hall", 0) request = FakeHomeAssistant.requests[-1] self.assertEqual(request["path"], "/api/services/light/turn_off") self.assertEqual( json.loads(request["body"]), {"entity_id": "light.hall"}, ) def test_brightness_rejects_invalid_values_before_any_request(self) -> None: for value in (-1, 101, 1.5, "bright"): with self.subTest(value=value): with self.assertRaisesRegex(ValueError, "invalid-brightness"): bridge.set_brightness(self.config(), "light.kitchen", value) self.assertEqual(FakeHomeAssistant.requests, []) def test_parse_brightness_rejects_invalid_cli_values(self) -> None: for value in ("-1", "101", "1.5", "bright"): with self.subTest(value=value): with self.assertRaisesRegex(ValueError, "invalid-brightness"): bridge.parse_brightness(value) def test_qml_refreshes_through_the_catalog_command(self) -> None: self.assertIn('command: [root.helperPath, "catalog"]', QML_SERVICE.read_text()) def test_qml_composes_catalog_and_preferences_for_selected_entities(self) -> None: source = QML_SERVICE.read_text() self.assertIn( "root.catalog = Array.isArray(result.entities) ? result.entities : [];", source, ) self.assertIn( "const favorites = root.fixtureMode\n" " ? root.fixtureFavorites\n" " : HomePreferences.favorites;", source, ) self.assertIn("name: alias || entity.sourceName,", source) self.assertIn("root.selectedEntities = nextSelection;", source) 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()