Merge Home & Phone into a three-tab Home that knows your house
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
This commit is contained in:
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
@@ -16,6 +18,23 @@ 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)
|
||||
@@ -25,6 +44,8 @@ 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
|
||||
@@ -47,6 +68,25 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
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/":
|
||||
@@ -95,6 +135,9 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
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",
|
||||
@@ -121,6 +164,7 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
FakeHomeAssistant.requests.clear()
|
||||
FakeHomeAssistant.template_mode = "rendered"
|
||||
|
||||
def config(self) -> object:
|
||||
return bridge.Config(
|
||||
@@ -129,6 +173,15 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
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",
|
||||
@@ -203,6 +256,108 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
["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")
|
||||
@@ -271,6 +426,28 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
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"),
|
||||
|
||||
Reference in New Issue
Block a user