Add Home Assistant light catalog and dimming
This commit is contained in:
@@ -18,7 +18,6 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
ENV_KEYS = (
|
ENV_KEYS = (
|
||||||
@@ -49,7 +48,7 @@ class Config:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.base_url and self.token and self.entity_ids)
|
return bool(self.base_url and self.token)
|
||||||
|
|
||||||
|
|
||||||
def compact_json(value: dict[str, object]) -> str:
|
def compact_json(value: dict[str, object]) -> str:
|
||||||
@@ -226,7 +225,7 @@ def request_json(
|
|||||||
config: Config,
|
config: Config,
|
||||||
method: str,
|
method: str,
|
||||||
path: str,
|
path: str,
|
||||||
payload: dict[str, str] | None = None,
|
payload: Mapping[str, object] | None = None,
|
||||||
) -> object:
|
) -> object:
|
||||||
if not config.base_url or not config.token:
|
if not config.base_url or not config.token:
|
||||||
raise BridgeError("not-configured")
|
raise BridgeError("not-configured")
|
||||||
@@ -266,71 +265,74 @@ def fallback_name(entity_id: str) -> str:
|
|||||||
return entity_id.split(".", 1)[1].replace("_", " ").title()
|
return entity_id.split(".", 1)[1].replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
def normalize_entities(
|
def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
|
||||||
raw: list[dict[str, Any]],
|
|
||||||
configured: Sequence[str],
|
|
||||||
) -> list[dict[str, object]]:
|
|
||||||
by_id = {
|
|
||||||
str(item.get("entity_id", "")): item
|
|
||||||
for item in raw
|
|
||||||
if isinstance(item, dict)
|
|
||||||
}
|
|
||||||
result: list[dict[str, object]] = []
|
result: list[dict[str, object]] = []
|
||||||
for entity_id in configured:
|
for item in raw:
|
||||||
item = by_id.get(entity_id)
|
if not isinstance(item, dict):
|
||||||
if not item:
|
|
||||||
continue
|
continue
|
||||||
|
entity_id = item.get("entity_id")
|
||||||
attributes = item.get("attributes")
|
attributes = item.get("attributes")
|
||||||
if not isinstance(attributes, dict):
|
if not isinstance(entity_id, str) or not entity_id.startswith("light."):
|
||||||
|
continue
|
||||||
|
if not ENTITY_ID.fullmatch(entity_id) or not isinstance(attributes, dict):
|
||||||
continue
|
continue
|
||||||
state = str(item.get("state", "unavailable"))
|
state = str(item.get("state", "unavailable"))
|
||||||
available = state not in {"unknown", "unavailable"}
|
available = state not in {"unknown", "unavailable"}
|
||||||
friendly_name = attributes.get("friendly_name")
|
active = available and state == "on"
|
||||||
name = (
|
raw_brightness = attributes.get("brightness")
|
||||||
friendly_name.strip()
|
brightness_pct = (
|
||||||
if isinstance(friendly_name, str) and friendly_name.strip()
|
round(max(0, min(255, raw_brightness)) * 100 / 255)
|
||||||
else fallback_name(entity_id)
|
if active
|
||||||
|
and isinstance(raw_brightness, (int, float))
|
||||||
|
and not isinstance(raw_brightness, bool)
|
||||||
|
else 0
|
||||||
)
|
)
|
||||||
|
modes = attributes.get("supported_color_modes", [])
|
||||||
|
dimmable = (
|
||||||
|
isinstance(modes, list) and any(mode != "onoff" for mode in modes)
|
||||||
|
) or isinstance(raw_brightness, (int, float))
|
||||||
|
source_name = attributes.get("friendly_name")
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"id": entity_id,
|
"id": entity_id,
|
||||||
"name": name,
|
"sourceName": (
|
||||||
"domain": entity_id.split(".", 1)[0],
|
source_name.strip()
|
||||||
|
if isinstance(source_name, str) and source_name.strip()
|
||||||
|
else fallback_name(entity_id)
|
||||||
|
),
|
||||||
"state": state,
|
"state": state,
|
||||||
"available": available,
|
"available": available,
|
||||||
"active": available
|
"active": active,
|
||||||
and state not in {"off", "closed", "idle", "standby"},
|
"dimmable": dimmable,
|
||||||
|
"brightnessPct": brightness_pct,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def ensure_configured(entity_id: str, configured: Sequence[str]) -> str:
|
def collect_catalog(config: Config) -> dict[str, object]:
|
||||||
if entity_id not in configured:
|
legacy_entity_ids = list(config.entity_ids)
|
||||||
raise ValueError("entity-not-configured")
|
|
||||||
return entity_id
|
|
||||||
|
|
||||||
|
|
||||||
def collect_snapshot(config: Config) -> dict[str, object]:
|
|
||||||
if not config.configured:
|
if not config.configured:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"configured": False,
|
"configured": False,
|
||||||
"generatedAt": int(time.time()),
|
"generatedAt": int(time.time()),
|
||||||
"entities": [],
|
"entities": [],
|
||||||
|
"legacyEntityIds": legacy_entity_ids,
|
||||||
"error": "not-configured",
|
"error": "not-configured",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
raw = request_json(config, "GET", "/api/states")
|
raw = request_json(config, "GET", "/api/states")
|
||||||
if not isinstance(raw, list):
|
if not isinstance(raw, list):
|
||||||
raise BridgeError("invalid-response")
|
raise BridgeError("invalid-response")
|
||||||
entities = normalize_entities(raw, config.entity_ids)
|
entities = normalize_catalog(raw)
|
||||||
except BridgeError as error:
|
except BridgeError as error:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"configured": True,
|
"configured": True,
|
||||||
"generatedAt": int(time.time()),
|
"generatedAt": int(time.time()),
|
||||||
"entities": [],
|
"entities": [],
|
||||||
|
"legacyEntityIds": legacy_entity_ids,
|
||||||
"error": str(error),
|
"error": str(error),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -338,10 +340,28 @@ def collect_snapshot(config: Config) -> dict[str, object]:
|
|||||||
"configured": True,
|
"configured": True,
|
||||||
"generatedAt": int(time.time()),
|
"generatedAt": int(time.time()),
|
||||||
"entities": entities,
|
"entities": entities,
|
||||||
|
"legacyEntityIds": legacy_entity_ids,
|
||||||
"error": "",
|
"error": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_snapshot(config: Config) -> dict[str, object]:
|
||||||
|
return collect_catalog(config)
|
||||||
|
|
||||||
|
|
||||||
|
def discovered_light_ids(config: Config) -> set[str]:
|
||||||
|
raw = request_json(config, "GET", "/api/states")
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise BridgeError("invalid-response")
|
||||||
|
return {item["id"] for item in normalize_catalog(raw) if isinstance(item["id"], str)}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_discovered(config: Config, entity_id: str) -> str:
|
||||||
|
if entity_id not in discovered_light_ids(config):
|
||||||
|
raise ValueError("entity-not-discovered")
|
||||||
|
return entity_id
|
||||||
|
|
||||||
|
|
||||||
def probe(config: Config) -> dict[str, object]:
|
def probe(config: Config) -> dict[str, object]:
|
||||||
if not config.configured:
|
if not config.configured:
|
||||||
return {
|
return {
|
||||||
@@ -368,7 +388,7 @@ def probe(config: Config) -> dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
def toggle(config: Config, entity_id: str) -> dict[str, object]:
|
def toggle(config: Config, entity_id: str) -> dict[str, object]:
|
||||||
entity_id = ensure_configured(entity_id, config.entity_ids)
|
entity_id = ensure_discovered(config, entity_id)
|
||||||
request_json(
|
request_json(
|
||||||
config,
|
config,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -378,6 +398,31 @@ def toggle(config: Config, entity_id: str) -> dict[str, object]:
|
|||||||
return {"ok": True, "entityId": entity_id, "error": ""}
|
return {"ok": True, "entityId": entity_id, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def set_brightness(
|
||||||
|
config: Config, entity_id: str, percent: int
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if isinstance(percent, bool) or not isinstance(percent, int) or not 0 <= percent <= 100:
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
ensure_discovered(config, entity_id)
|
||||||
|
if percent == 0:
|
||||||
|
path = "/api/services/light/turn_off"
|
||||||
|
payload = {"entity_id": entity_id}
|
||||||
|
else:
|
||||||
|
path = "/api/services/light/turn_on"
|
||||||
|
payload = {"entity_id": entity_id, "brightness_pct": percent}
|
||||||
|
request_json(config, "POST", path, payload)
|
||||||
|
return {"ok": True, "entityId": entity_id, "brightnessPct": percent, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_brightness(value: str) -> int:
|
||||||
|
if not re.fullmatch(r"(?:0|[1-9][0-9]{0,2})", value):
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
percent = int(value)
|
||||||
|
if percent > 100:
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
return percent
|
||||||
|
|
||||||
|
|
||||||
def open_home(config: Config) -> dict[str, object]:
|
def open_home(config: Config) -> dict[str, object]:
|
||||||
if not config.base_url:
|
if not config.base_url:
|
||||||
return {"ok": False, "error": "not-configured"}
|
return {"ok": False, "error": "not-configured"}
|
||||||
@@ -409,8 +454,8 @@ def main(argv: list[str]) -> int:
|
|||||||
if command == "probe" and len(argv) <= 1:
|
if command == "probe" and len(argv) <= 1:
|
||||||
result = probe(config)
|
result = probe(config)
|
||||||
success = bool(result["reachable"])
|
success = bool(result["reachable"])
|
||||||
elif command == "snapshot" and len(argv) == 1:
|
elif command in {"catalog", "snapshot"} and len(argv) == 1:
|
||||||
result = collect_snapshot(config)
|
result = collect_catalog(config)
|
||||||
success = bool(result["ok"])
|
success = bool(result["ok"])
|
||||||
elif command == "toggle" and len(argv) == 2:
|
elif command == "toggle" and len(argv) == 2:
|
||||||
try:
|
try:
|
||||||
@@ -420,6 +465,14 @@ def main(argv: list[str]) -> int:
|
|||||||
except BridgeError as error:
|
except BridgeError as error:
|
||||||
result = {"ok": False, "error": str(error)}
|
result = {"ok": False, "error": str(error)}
|
||||||
success = bool(result["ok"])
|
success = bool(result["ok"])
|
||||||
|
elif command == "brightness" and len(argv) == 3:
|
||||||
|
try:
|
||||||
|
result = set_brightness(config, argv[1], parse_brightness(argv[2]))
|
||||||
|
except ValueError as error:
|
||||||
|
result = {"ok": False, "error": str(error)}
|
||||||
|
except BridgeError as error:
|
||||||
|
result = {"ok": False, "error": str(error)}
|
||||||
|
success = bool(result["ok"])
|
||||||
elif command == "open" and len(argv) == 1:
|
elif command == "open" and len(argv) == 1:
|
||||||
result = open_home(config)
|
result = open_home(config)
|
||||||
success = bool(result["ok"])
|
success = bool(result["ok"])
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
// Home Assistant state for the Control Center. Credentials and REST details
|
// Home Assistant state for the Control Center. Credentials and REST details
|
||||||
// remain behind the helper; QML receives configured favourites only.
|
// remain behind the helper; QML receives a normalized light catalog only.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
@@ -151,7 +151,7 @@ Singleton {
|
|||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: refreshProc
|
id: refreshProc
|
||||||
command: [root.helperPath, "snapshot"]
|
command: [root.helperPath, "catalog"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: root.consumeSnapshot(this.text)
|
onStreamFinished: root.consumeSnapshot(this.text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,24 +12,14 @@ helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
|
|||||||
|
|
||||||
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
||||||
|
|
||||||
probe="$($helper probe)"
|
catalog="$($helper catalog)"
|
||||||
jq -e '
|
jq -e '
|
||||||
.configured == true and
|
.ok == true and .configured == true and .error == "" and
|
||||||
.reachable == true and
|
(.entities | type == "array" and length > 0) and
|
||||||
(.entityCount | type == "number" and . > 0) and
|
([.entities[] | (keys | sort) == (["active", "available", "brightnessPct", "dimmable", "id", "sourceName", "state"] | sort)] | all) and
|
||||||
.error == ""
|
([.entities[] | (.id | startswith("light.")) and (.brightnessPct >= 0 and .brightnessPct <= 100)] | all) and
|
||||||
' <<<"$probe" >/dev/null || fail 'live API probe failed'
|
(.legacyEntityIds | type == "array")
|
||||||
|
' <<<"$catalog" >/dev/null || fail 'live catalog shape is invalid'
|
||||||
|
|
||||||
snapshot="$($helper snapshot)"
|
printf 'Home Assistant helper contract: PASS (configured=true, %s lights; contents redacted)\n' \
|
||||||
jq -e --argjson expected "$(jq '.entityCount' <<<"$probe")" '
|
"$(jq '.entities | length' <<<"$catalog")"
|
||||||
.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")"
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||||
HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant"
|
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))
|
loader = importlib.machinery.SourceFileLoader("panama_home_assistant", str(HELPER))
|
||||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
@@ -54,20 +55,36 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
|||||||
if self.path == "/api/states":
|
if self.path == "/api/states":
|
||||||
self._json(
|
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",
|
"entity_id": "light.kitchen",
|
||||||
"state": "on",
|
"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"))
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
body = self.rfile.read(length)
|
body = self.rfile.read(length)
|
||||||
self._record(body)
|
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([])
|
self._json([])
|
||||||
return
|
return
|
||||||
self._json({"message": "not found"}, 404)
|
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.base_url, "https://home.example")
|
||||||
self.assertEqual(config.entity_ids, ("light.kitchen", "light.hall"))
|
self.assertEqual(config.entity_ids, ("light.kitchen", "light.hall"))
|
||||||
|
|
||||||
def test_snapshot_filters_and_preserves_configured_order(self) -> None:
|
def test_config_does_not_require_legacy_entity_ids(self) -> None:
|
||||||
snapshot = bridge.collect_snapshot(self.config())
|
self.assertTrue(
|
||||||
|
bridge.Config(
|
||||||
self.assertTrue(snapshot["ok"])
|
base_url="https://home.example",
|
||||||
self.assertEqual(
|
token="fixture-token",
|
||||||
[item["name"] for item in snapshot["entities"]],
|
entity_ids=(),
|
||||||
["Kitchen", "Hall"],
|
).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:
|
def test_snapshot_uses_bearer_authentication(self) -> None:
|
||||||
bridge.collect_snapshot(self.config())
|
bridge.collect_catalog(self.config())
|
||||||
|
|
||||||
request = FakeHomeAssistant.requests[-1]
|
request = FakeHomeAssistant.requests[-1]
|
||||||
self.assertEqual(request["method"], "GET")
|
self.assertEqual(request["method"], "GET")
|
||||||
self.assertEqual(request["path"], "/api/states")
|
self.assertEqual(request["path"], "/api/states")
|
||||||
self.assertEqual(request["authorization"], "Bearer fixture-token")
|
self.assertEqual(request["authorization"], "Bearer fixture-token")
|
||||||
|
|
||||||
def test_toggle_rejects_an_unconfigured_entity(self) -> None:
|
def test_snapshot_remains_a_catalog_compatibility_alias(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "entity-not-configured"):
|
snapshot = bridge.collect_snapshot(self.config())
|
||||||
bridge.ensure_configured(
|
|
||||||
"light.office",
|
|
||||||
("light.kitchen",),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_toggle_calls_the_homeassistant_service(self) -> None:
|
self.assertTrue(snapshot["ok"])
|
||||||
result = bridge.toggle(self.config(), "light.kitchen")
|
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"])
|
self.assertTrue(result["ok"])
|
||||||
request = FakeHomeAssistant.requests[-1]
|
request = FakeHomeAssistant.requests[-1]
|
||||||
@@ -157,9 +216,45 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
|||||||
self.assertEqual(request["path"], "/api/services/homeassistant/toggle")
|
self.assertEqual(request["path"], "/api/services/homeassistant/toggle")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
json.loads(request["body"]),
|
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:
|
def test_authentication_error_is_redacted(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
bridge.public_http_error(401, "sensitive response"),
|
bridge.public_http_error(401, "sensitive response"),
|
||||||
|
|||||||
Reference in New Issue
Block a user