2026-08-30 14:26:11 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import requests
|
|
|
|
|
import urllib3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Settings:
|
|
|
|
|
host: str
|
|
|
|
|
application_key: str
|
|
|
|
|
verify_tls: bool = False
|
2026-09-06 15:31:57 +02:00
|
|
|
interval: int = 30
|
2026-08-30 14:26:11 +02:00
|
|
|
listen_host: str = "0.0.0.0"
|
|
|
|
|
listen_port: int = 8000
|
2026-09-06 15:31:57 +02:00
|
|
|
data_dir: str = "/data"
|
2026-08-30 14:26:11 +02:00
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_env(cls) -> "Settings":
|
|
|
|
|
host = os.environ.get("HUE_BRIDGE_HOST", "").strip()
|
|
|
|
|
key = os.environ.get("HUE_APPLICATION_KEY", "").strip()
|
|
|
|
|
if not host or not key:
|
|
|
|
|
raise ValueError("HUE_BRIDGE_HOST and HUE_APPLICATION_KEY must be set")
|
|
|
|
|
return cls(
|
|
|
|
|
host=host.removeprefix("https://").removeprefix("http://").rstrip("/"),
|
|
|
|
|
application_key=key,
|
|
|
|
|
verify_tls=os.environ.get("HUE_VERIFY_TLS", "false").lower() in {"1", "true", "yes"},
|
2026-09-06 15:31:57 +02:00
|
|
|
interval=int(os.environ.get("COLLECT_INTERVAL_SECONDS", "30")),
|
2026-08-30 14:26:11 +02:00
|
|
|
listen_host=os.environ.get("LISTEN_HOST", "0.0.0.0"),
|
|
|
|
|
listen_port=int(os.environ.get("LISTEN_PORT", "8000")),
|
2026-09-06 15:31:57 +02:00
|
|
|
data_dir=os.environ.get("DATA_DIR", "/data"),
|
2026-08-30 14:26:11 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class HueClient:
|
|
|
|
|
def __init__(self, settings: Settings):
|
|
|
|
|
self.base_url = f"https://{settings.host}/clip/v2/resource"
|
|
|
|
|
self.verify = settings.verify_tls
|
|
|
|
|
self.session = requests.Session()
|
|
|
|
|
self.session.headers["hue-application-key"] = settings.application_key
|
|
|
|
|
if not self.verify:
|
|
|
|
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
|
|
|
|
|
|
|
|
def get(self, resource: str) -> list[dict[str, Any]]:
|
|
|
|
|
response = self.session.get(f"{self.base_url}/{resource}", verify=self.verify, timeout=10)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
body = response.json()
|
|
|
|
|
if body.get("errors"):
|
|
|
|
|
raise RuntimeError(f"Hue API error for {resource}: {body['errors']}")
|
|
|
|
|
return body.get("data", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Collector:
|
|
|
|
|
RESOURCE_TYPES = ("device", "room", "zone", "light", "temperature", "light_level", "zigbee_connectivity")
|
|
|
|
|
|
|
|
|
|
def __init__(self, client: HueClient):
|
|
|
|
|
self.client = client
|
|
|
|
|
|
2026-09-06 15:31:57 +02:00
|
|
|
def collect(self) -> dict[str, list[dict[str, Any]]]:
|
2026-08-30 14:26:11 +02:00
|
|
|
resources = {kind: self.client.get(kind) for kind in self.RESOURCE_TYPES}
|
|
|
|
|
devices = {item["id"]: item for item in resources["device"]}
|
|
|
|
|
connected = {
|
|
|
|
|
item.get("owner", {}).get("rid"): item.get("status") == "connected"
|
|
|
|
|
for item in resources["zigbee_connectivity"]
|
|
|
|
|
}
|
|
|
|
|
light_by_device = self._by_owner(resources["light"])
|
|
|
|
|
room_for_device, zones_for_device = self._locations(resources["room"], resources["zone"])
|
|
|
|
|
|
2026-09-06 15:31:57 +02:00
|
|
|
groups: list[dict[str, Any]] = []
|
2026-08-30 14:26:11 +02:00
|
|
|
for kind in ("room", "zone"):
|
|
|
|
|
for group in resources[kind]:
|
2026-09-06 15:31:57 +02:00
|
|
|
all_lights = [light for child in group.get("children", []) for light in light_by_device.get(child.get("rid"), [])]
|
|
|
|
|
available = [
|
|
|
|
|
light for child in group.get("children", [])
|
2026-08-30 14:26:11 +02:00
|
|
|
for light in light_by_device.get(child.get("rid"), [])
|
|
|
|
|
if connected.get(child.get("rid"), True)
|
|
|
|
|
]
|
2026-09-06 15:31:57 +02:00
|
|
|
shining = [light for light in available if light.get("on", {}).get("on", False)]
|
2026-08-30 14:26:11 +02:00
|
|
|
brightness = [light.get("dimming", {}).get("brightness") for light in shining]
|
|
|
|
|
brightness = [value for value in brightness if value is not None]
|
2026-09-06 15:31:57 +02:00
|
|
|
groups.append({
|
|
|
|
|
"id": group["id"], "type": kind,
|
|
|
|
|
"name": group.get("metadata", {}).get("name", group["id"]),
|
|
|
|
|
"available": len(available), "on": len(shining),
|
|
|
|
|
"off": len(available) - len(shining),
|
|
|
|
|
"unavailable": len(all_lights) - len(available),
|
|
|
|
|
"brightness": sum(brightness) / len(brightness) if brightness else 0.0,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
sensors: list[dict[str, Any]] = []
|
|
|
|
|
for resource_type, field, unit in (("temperature", "temperature", "°C"), ("light_level", "light_level", "lux")):
|
|
|
|
|
for sensor in resources[resource_type]:
|
|
|
|
|
device_id = sensor.get("owner", {}).get("rid", "")
|
|
|
|
|
device = devices.get(device_id, {})
|
|
|
|
|
value = sensor.get(resource_type, {}).get(field)
|
|
|
|
|
valid = not (resource_type == "light_level" and not sensor.get("light_level", {}).get("light_level_valid", True))
|
|
|
|
|
available = connected.get(device_id, True)
|
|
|
|
|
sensors.append({
|
|
|
|
|
"id": sensor["id"], "type": resource_type,
|
|
|
|
|
"name": sensor.get("metadata", {}).get("name") or device.get("metadata", {}).get("name", sensor["id"]),
|
|
|
|
|
"room": room_for_device.get(device_id, ""), "zones": zones_for_device.get(device_id, ""),
|
|
|
|
|
"available": available, "value": value if available and valid else None, "unit": unit,
|
|
|
|
|
})
|
|
|
|
|
return {"groups": groups, "sensors": sensors}
|
2026-08-30 14:26:11 +02:00
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _by_owner(items: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
|
|
|
|
result: dict[str, list[dict[str, Any]]] = {}
|
|
|
|
|
for item in items:
|
|
|
|
|
result.setdefault(item.get("owner", {}).get("rid", ""), []).append(item)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _locations(rooms: list[dict[str, Any]], zones: list[dict[str, Any]]) -> tuple[dict[str, str], dict[str, str]]:
|
|
|
|
|
room_for_device: dict[str, str] = {}
|
|
|
|
|
zones_for_device: dict[str, list[str]] = {}
|
|
|
|
|
for room in rooms:
|
|
|
|
|
for child in room.get("children", []):
|
2026-09-06 15:31:57 +02:00
|
|
|
room_for_device[child.get("rid", "")] = room.get("metadata", {}).get("name", room["id"])
|
2026-08-30 14:26:11 +02:00
|
|
|
for zone in zones:
|
|
|
|
|
for child in zone.get("children", []):
|
2026-09-06 15:31:57 +02:00
|
|
|
zones_for_device.setdefault(child.get("rid", ""), []).append(zone.get("metadata", {}).get("name", zone["id"]))
|
2026-08-30 14:26:11 +02:00
|
|
|
return room_for_device, {key: ", ".join(sorted(value)) for key, value in zones_for_device.items()}
|