Add Home Assistant light catalog and dimming
This commit is contained in:
@@ -18,7 +18,6 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
ENV_KEYS = (
|
||||
@@ -49,7 +48,7 @@ class Config:
|
||||
|
||||
@property
|
||||
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:
|
||||
@@ -226,7 +225,7 @@ def request_json(
|
||||
config: Config,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, str] | None = None,
|
||||
payload: Mapping[str, object] | None = None,
|
||||
) -> object:
|
||||
if not config.base_url or not config.token:
|
||||
raise BridgeError("not-configured")
|
||||
@@ -266,71 +265,74 @@ def fallback_name(entity_id: str) -> str:
|
||||
return entity_id.split(".", 1)[1].replace("_", " ").title()
|
||||
|
||||
|
||||
def normalize_entities(
|
||||
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)
|
||||
}
|
||||
def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
|
||||
result: list[dict[str, object]] = []
|
||||
for entity_id in configured:
|
||||
item = by_id.get(entity_id)
|
||||
if not item:
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
entity_id = item.get("entity_id")
|
||||
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
|
||||
state = str(item.get("state", "unavailable"))
|
||||
available = state not in {"unknown", "unavailable"}
|
||||
friendly_name = attributes.get("friendly_name")
|
||||
name = (
|
||||
friendly_name.strip()
|
||||
if isinstance(friendly_name, str) and friendly_name.strip()
|
||||
else fallback_name(entity_id)
|
||||
active = available and state == "on"
|
||||
raw_brightness = attributes.get("brightness")
|
||||
brightness_pct = (
|
||||
round(max(0, min(255, raw_brightness)) * 100 / 255)
|
||||
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(
|
||||
{
|
||||
"id": entity_id,
|
||||
"name": name,
|
||||
"domain": entity_id.split(".", 1)[0],
|
||||
"sourceName": (
|
||||
source_name.strip()
|
||||
if isinstance(source_name, str) and source_name.strip()
|
||||
else fallback_name(entity_id)
|
||||
),
|
||||
"state": state,
|
||||
"available": available,
|
||||
"active": available
|
||||
and state not in {"off", "closed", "idle", "standby"},
|
||||
"active": active,
|
||||
"dimmable": dimmable,
|
||||
"brightnessPct": brightness_pct,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def ensure_configured(entity_id: str, configured: Sequence[str]) -> str:
|
||||
if entity_id not in configured:
|
||||
raise ValueError("entity-not-configured")
|
||||
return entity_id
|
||||
|
||||
|
||||
def collect_snapshot(config: Config) -> dict[str, object]:
|
||||
def collect_catalog(config: Config) -> dict[str, object]:
|
||||
legacy_entity_ids = list(config.entity_ids)
|
||||
if not config.configured:
|
||||
return {
|
||||
"ok": False,
|
||||
"configured": False,
|
||||
"generatedAt": int(time.time()),
|
||||
"entities": [],
|
||||
"legacyEntityIds": legacy_entity_ids,
|
||||
"error": "not-configured",
|
||||
}
|
||||
try:
|
||||
raw = request_json(config, "GET", "/api/states")
|
||||
if not isinstance(raw, list):
|
||||
raise BridgeError("invalid-response")
|
||||
entities = normalize_entities(raw, config.entity_ids)
|
||||
entities = normalize_catalog(raw)
|
||||
except BridgeError as error:
|
||||
return {
|
||||
"ok": False,
|
||||
"configured": True,
|
||||
"generatedAt": int(time.time()),
|
||||
"entities": [],
|
||||
"legacyEntityIds": legacy_entity_ids,
|
||||
"error": str(error),
|
||||
}
|
||||
return {
|
||||
@@ -338,10 +340,28 @@ def collect_snapshot(config: Config) -> dict[str, object]:
|
||||
"configured": True,
|
||||
"generatedAt": int(time.time()),
|
||||
"entities": entities,
|
||||
"legacyEntityIds": legacy_entity_ids,
|
||||
"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]:
|
||||
if not config.configured:
|
||||
return {
|
||||
@@ -368,7 +388,7 @@ def probe(config: Config) -> 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(
|
||||
config,
|
||||
"POST",
|
||||
@@ -378,6 +398,31 @@ def toggle(config: Config, entity_id: str) -> dict[str, object]:
|
||||
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]:
|
||||
if not config.base_url:
|
||||
return {"ok": False, "error": "not-configured"}
|
||||
@@ -409,8 +454,8 @@ def main(argv: list[str]) -> int:
|
||||
if command == "probe" and len(argv) <= 1:
|
||||
result = probe(config)
|
||||
success = bool(result["reachable"])
|
||||
elif command == "snapshot" and len(argv) == 1:
|
||||
result = collect_snapshot(config)
|
||||
elif command in {"catalog", "snapshot"} and len(argv) == 1:
|
||||
result = collect_catalog(config)
|
||||
success = bool(result["ok"])
|
||||
elif command == "toggle" and len(argv) == 2:
|
||||
try:
|
||||
@@ -420,6 +465,14 @@ def main(argv: list[str]) -> int:
|
||||
except BridgeError as error:
|
||||
result = {"ok": False, "error": str(error)}
|
||||
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:
|
||||
result = open_home(config)
|
||||
success = bool(result["ok"])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pragma Singleton
|
||||
|
||||
// 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.Io
|
||||
@@ -151,7 +151,7 @@ Singleton {
|
||||
|
||||
Process {
|
||||
id: refreshProc
|
||||
command: [root.helperPath, "snapshot"]
|
||||
command: [root.helperPath, "catalog"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.consumeSnapshot(this.text)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user