Home is now Overview | My Home | Phone. Overview leads with quick-action tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the findings card — updates fold in, the reclaim-space prompt is gone on purpose — and adds glance cards, the next calendar event, and weather. My Home groups every light by Home Assistant area: the helper gained an `areas` command (one REST template render, no websocket), and the rooms degrade to a flat list on setups without areas. The favorites editor and connection card moved intact. Phone gains a vitals strip — battery and cell signal read from KDE Connect's plugin D-Bus objects, where absence is data, not an error — beside ring, clipboard, send-a-file, and the BlueBubbles handoff. The retired home-phone id resolves to my-home forever via a new alias map in SettingsRoutes (with a hasOwnProperty guard so prototype names cannot leak into settingsPage). Storage no longer claims 0 B free — the old page read a field the disks helper never emitted. Contracts updated alongside; per the new workflow, the full suite runs once at the end of the redesign (see the test backlog note). Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
471 lines
18 KiB
Python
471 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import subprocess
|
|
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"
|
|
|
|
# What a Home Assistant would render for the areas template: every area it
|
|
# holds, including ones whose entities are not lights, plus entries no template
|
|
# should produce but a bridge still has to survive.
|
|
TEMPLATE_AREAS = [
|
|
{
|
|
"id": "kitchen",
|
|
"name": "Kitchen",
|
|
"entities": ["light.kitchen", "switch.kettle", "sensor.private"],
|
|
},
|
|
{"id": "hallway", "name": "Hall", "entities": ["light.hall", "light.corner"]},
|
|
{"id": "garage", "name": "Garage", "entities": ["sensor.garage_door"]},
|
|
{"id": 12, "name": "Numeric", "entities": ["light.kitchen"]},
|
|
{"id": "unnamed", "name": "", "entities": ["light.kitchen"]},
|
|
{"id": "not_a_list", "name": "Loose", "entities": "light.kitchen"},
|
|
"not-an-area",
|
|
]
|
|
|
|
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]] = []
|
|
# "rendered" | "malformed" | "unauthorized"
|
|
template_mode: str = "rendered"
|
|
|
|
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)
|
|
|
|
# Home Assistant answers /api/template with the rendered text, not with a
|
|
# JSON document, so the fixture replies in kind.
|
|
def _text(self, body: str, status: int = 200) -> None:
|
|
payload = body.encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def _template(self) -> None:
|
|
if self.template_mode == "unauthorized":
|
|
self._json({"message": "Unauthorized: token abc123"}, 401)
|
|
return
|
|
if self.template_mode == "malformed":
|
|
self._text("Error rendering template: UndefinedError")
|
|
return
|
|
self._text(json.dumps(TEMPLATE_AREAS))
|
|
|
|
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 == "/api/template":
|
|
self._template()
|
|
return
|
|
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()
|
|
FakeHomeAssistant.template_mode = "rendered"
|
|
|
|
def config(self) -> object:
|
|
return bridge.Config(
|
|
base_url=self.base_url,
|
|
token="fixture-token",
|
|
entity_ids=("light.kitchen", "light.hall"),
|
|
)
|
|
|
|
def helper_env(self) -> dict[str, str]:
|
|
return {
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
"HOME": os.environ.get("HOME", "/tmp"),
|
|
"PANAMA_HOME_ASSISTANT_URL": self.base_url,
|
|
"PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token",
|
|
"PANAMA_HOME_ASSISTANT_ENTITIES": "light.kitchen",
|
|
}
|
|
|
|
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_areas_group_lights_by_home_assistant_area(self) -> None:
|
|
result = bridge.collect_areas(self.config())
|
|
|
|
self.assertTrue(result["ok"])
|
|
self.assertEqual(result["error"], "")
|
|
self.assertEqual(
|
|
result["areas"],
|
|
[
|
|
{"id": "kitchen", "name": "Kitchen", "entities": ["light.kitchen"]},
|
|
{
|
|
"id": "hallway",
|
|
"name": "Hall",
|
|
"entities": ["light.hall", "light.corner"],
|
|
},
|
|
],
|
|
)
|
|
|
|
def test_areas_render_the_whole_list_from_one_template_request(self) -> None:
|
|
bridge.collect_areas(self.config())
|
|
|
|
request = FakeHomeAssistant.requests[-1]
|
|
self.assertEqual(len(FakeHomeAssistant.requests), 1)
|
|
self.assertEqual(request["method"], "POST")
|
|
self.assertEqual(request["path"], "/api/template")
|
|
self.assertEqual(request["authorization"], "Bearer fixture-token")
|
|
template = json.loads(request["body"])["template"]
|
|
for fragment in ("areas()", "area_name(area)", "area_entities(area)", "tojson"):
|
|
self.assertIn(fragment, template)
|
|
|
|
def test_areas_keep_only_lights_and_drop_areas_left_with_none(self) -> None:
|
|
rendered = json.dumps(bridge.collect_areas(self.config()))
|
|
|
|
self.assertNotIn("switch.kettle", rendered)
|
|
self.assertNotIn("sensor.private", rendered)
|
|
self.assertNotIn("Garage", rendered)
|
|
|
|
def test_areas_discard_malformed_entries_without_losing_the_rest(self) -> None:
|
|
rendered = json.dumps(bridge.collect_areas(self.config()))
|
|
|
|
self.assertNotIn("Numeric", rendered)
|
|
self.assertNotIn("unnamed", rendered)
|
|
self.assertNotIn("Loose", rendered)
|
|
self.assertIn("Kitchen", rendered)
|
|
|
|
def test_areas_reject_a_template_response_that_is_not_json(self) -> None:
|
|
FakeHomeAssistant.template_mode = "malformed"
|
|
|
|
result = bridge.collect_areas(self.config())
|
|
|
|
self.assertFalse(result["ok"])
|
|
self.assertEqual(result["error"], "invalid-response")
|
|
self.assertEqual(result["areas"], [])
|
|
|
|
def test_areas_redact_an_authentication_failure(self) -> None:
|
|
FakeHomeAssistant.template_mode = "unauthorized"
|
|
|
|
result = bridge.collect_areas(self.config())
|
|
|
|
self.assertFalse(result["ok"])
|
|
self.assertEqual(result["error"], "authentication-required")
|
|
self.assertNotIn("abc123", json.dumps(result))
|
|
|
|
def test_areas_report_not_configured_before_any_request(self) -> None:
|
|
result = bridge.collect_areas(
|
|
bridge.Config(base_url=self.base_url, token="", entity_ids=())
|
|
)
|
|
|
|
self.assertEqual(
|
|
result, {"ok": False, "areas": [], "error": "not-configured"}
|
|
)
|
|
self.assertEqual(FakeHomeAssistant.requests, [])
|
|
|
|
def test_cli_areas_prints_one_json_object(self) -> None:
|
|
completed = subprocess.run(
|
|
[sys.executable, str(HELPER), "areas"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=self.helper_env(),
|
|
timeout=15,
|
|
)
|
|
|
|
self.assertEqual(completed.returncode, 0)
|
|
self.assertEqual(len(completed.stdout.strip().splitlines()), 1)
|
|
self.assertEqual(
|
|
[area["name"] for area in json.loads(completed.stdout)["areas"]],
|
|
["Kitchen", "Hall"],
|
|
)
|
|
|
|
def test_cli_still_rejects_unknown_arguments(self) -> None:
|
|
completed = subprocess.run(
|
|
[sys.executable, str(HELPER), "areas", "kitchen"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=self.helper_env(),
|
|
timeout=15,
|
|
)
|
|
|
|
self.assertEqual(completed.returncode, 2)
|
|
self.assertEqual(
|
|
json.loads(completed.stdout), {"ok": False, "error": "invalid-command"}
|
|
)
|
|
|
|
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_qml_fetches_areas_alongside_the_catalog(self) -> None:
|
|
source = QML_SERVICE.read_text()
|
|
|
|
self.assertIn('command: [root.helperPath, "areas"]', source)
|
|
self.assertIn("if (!areasProc.running)\n areasProc.running = true;", source)
|
|
|
|
def test_qml_composes_rooms_and_tolerates_missing_areas(self) -> None:
|
|
source = QML_SERVICE.read_text()
|
|
|
|
self.assertIn("property var areas: []", source)
|
|
self.assertIn(
|
|
"readonly property var rooms: root.composeRooms(root.catalog, root.areas)",
|
|
source,
|
|
)
|
|
self.assertIn('grouped.push({ id: "", name: "Other", lights: orphans });', source)
|
|
# A failed areas fetch empties the grouping and nothing else: no phase,
|
|
# no stale flag, no lastError, so lights keep working without it.
|
|
consume = source.split("function consumeAreas(text: string): void {", 1)[1]
|
|
consume = consume.split("\n }\n", 1)[0]
|
|
for untouched in ("root.phase", "root.stale", "root.lastError"):
|
|
self.assertNotIn(untouched, consume)
|
|
|
|
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()
|