Add Home Assistant light catalog and dimming
This commit is contained in:
@@ -12,24 +12,14 @@ helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
|
||||
|
||||
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
||||
|
||||
probe="$($helper probe)"
|
||||
catalog="$($helper catalog)"
|
||||
jq -e '
|
||||
.configured == true and
|
||||
.reachable == true and
|
||||
(.entityCount | type == "number" and . > 0) and
|
||||
.error == ""
|
||||
' <<<"$probe" >/dev/null || fail 'live API probe failed'
|
||||
.ok == true and .configured == true and .error == "" and
|
||||
(.entities | type == "array" and length > 0) and
|
||||
([.entities[] | (keys | sort) == (["active", "available", "brightnessPct", "dimmable", "id", "sourceName", "state"] | sort)] | all) and
|
||||
([.entities[] | (.id | startswith("light.")) and (.brightnessPct >= 0 and .brightnessPct <= 100)] | all) and
|
||||
(.legacyEntityIds | type == "array")
|
||||
' <<<"$catalog" >/dev/null || fail 'live catalog shape is invalid'
|
||||
|
||||
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")"
|
||||
printf 'Home Assistant helper contract: PASS (configured=true, %s lights; contents redacted)\n' \
|
||||
"$(jq '.entities | length' <<<"$catalog")"
|
||||
|
||||
@@ -14,6 +14,7 @@ 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)
|
||||
@@ -54,20 +55,36 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
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"},
|
||||
"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"],
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
@@ -78,7 +95,11 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length)
|
||||
self._record(body)
|
||||
if self.path == "/api/services/homeassistant/toggle":
|
||||
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)
|
||||
@@ -123,33 +144,71 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
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"],
|
||||
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
|
||||
)
|
||||
self.assertNotIn("sensor.private", json.dumps(snapshot))
|
||||
|
||||
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_snapshot(self.config())
|
||||
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_toggle_rejects_an_unconfigured_entity(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "entity-not-configured"):
|
||||
bridge.ensure_configured(
|
||||
"light.office",
|
||||
("light.kitchen",),
|
||||
)
|
||||
def test_snapshot_remains_a_catalog_compatibility_alias(self) -> None:
|
||||
snapshot = bridge.collect_snapshot(self.config())
|
||||
|
||||
def test_toggle_calls_the_homeassistant_service(self) -> None:
|
||||
result = bridge.toggle(self.config(), "light.kitchen")
|
||||
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]
|
||||
@@ -157,9 +216,45 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
self.assertEqual(request["path"], "/api/services/homeassistant/toggle")
|
||||
self.assertEqual(
|
||||
json.loads(request["body"]),
|
||||
{"entity_id": "light.kitchen"},
|
||||
{"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_authentication_error_is_redacted(self) -> None:
|
||||
self.assertEqual(
|
||||
bridge.public_http_error(401, "sensitive response"),
|
||||
|
||||
Reference in New Issue
Block a user