2026-03-02 19:30:20 +01:00
|
|
|
|
"""Home Assistant integration – fetch entity states & call services."""
|
|
|
|
|
|
|
2026-03-02 01:48:51 +01:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-03-02 19:30:20 +01:00
|
|
|
|
import logging
|
2026-03-02 01:48:51 +01:00
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
2026-03-02 19:30:20 +01:00
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Helpers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-03-02 01:48:51 +01:00
|
|
|
|
|
|
|
|
|
|
def _friendly_name(entity: Dict[str, Any]) -> str:
|
|
|
|
|
|
"""Extract the friendly name from an entity's attributes, falling back to entity_id."""
|
|
|
|
|
|
attrs = entity.get("attributes", {})
|
|
|
|
|
|
return attrs.get("friendly_name", entity.get("entity_id", "unknown"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_light(entity: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
attrs = entity.get("attributes", {})
|
|
|
|
|
|
brightness_raw = attrs.get("brightness")
|
|
|
|
|
|
brightness_pct: Optional[int] = None
|
|
|
|
|
|
if brightness_raw is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
brightness_pct = round(int(brightness_raw) / 255 * 100)
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
brightness_pct = None
|
|
|
|
|
|
return {
|
|
|
|
|
|
"entity_id": entity.get("entity_id", ""),
|
|
|
|
|
|
"name": _friendly_name(entity),
|
2026-03-02 19:30:20 +01:00
|
|
|
|
"state": entity.get("state", "unknown"),
|
2026-03-02 01:48:51 +01:00
|
|
|
|
"brightness": brightness_pct,
|
|
|
|
|
|
"color_mode": attrs.get("color_mode"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-02 19:30:20 +01:00
|
|
|
|
def _parse_switch(entity: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"entity_id": entity.get("entity_id", ""),
|
|
|
|
|
|
"name": _friendly_name(entity),
|
|
|
|
|
|
"state": entity.get("state", "unknown"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-02 01:48:51 +01:00
|
|
|
|
def _parse_cover(entity: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
attrs = entity.get("attributes", {})
|
|
|
|
|
|
return {
|
|
|
|
|
|
"entity_id": entity.get("entity_id", ""),
|
|
|
|
|
|
"name": _friendly_name(entity),
|
|
|
|
|
|
"state": entity.get("state", "unknown"),
|
|
|
|
|
|
"current_position": attrs.get("current_position"),
|
2026-03-02 19:30:20 +01:00
|
|
|
|
"device_class": attrs.get("device_class", ""),
|
2026-03-02 01:48:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_sensor(entity: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
attrs = entity.get("attributes", {})
|
|
|
|
|
|
state_value = entity.get("state", "unknown")
|
|
|
|
|
|
try:
|
|
|
|
|
|
state_value = round(float(state_value), 1)
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
return {
|
|
|
|
|
|
"entity_id": entity.get("entity_id", ""),
|
|
|
|
|
|
"name": _friendly_name(entity),
|
|
|
|
|
|
"state": state_value,
|
|
|
|
|
|
"unit": attrs.get("unit_of_measurement", ""),
|
|
|
|
|
|
"device_class": attrs.get("device_class", ""),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-02 19:30:20 +01:00
|
|
|
|
def _parse_binary_sensor(entity: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
attrs = entity.get("attributes", {})
|
|
|
|
|
|
return {
|
|
|
|
|
|
"entity_id": entity.get("entity_id", ""),
|
|
|
|
|
|
"name": _friendly_name(entity),
|
|
|
|
|
|
"state": entity.get("state", "unknown"),
|
|
|
|
|
|
"device_class": attrs.get("device_class", ""),
|
|
|
|
|
|
}
|
2026-03-02 01:48:51 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-02 19:30:20 +01:00
|
|
|
|
def _parse_climate(entity: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
attrs = entity.get("attributes", {})
|
|
|
|
|
|
current_temp = attrs.get("current_temperature")
|
|
|
|
|
|
target_temp = attrs.get("temperature")
|
|
|
|
|
|
try:
|
|
|
|
|
|
current_temp = round(float(current_temp), 1) if current_temp is not None else None
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
current_temp = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
target_temp = round(float(target_temp), 1) if target_temp is not None else None
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
target_temp = None
|
|
|
|
|
|
return {
|
|
|
|
|
|
"entity_id": entity.get("entity_id", ""),
|
|
|
|
|
|
"name": _friendly_name(entity),
|
|
|
|
|
|
"state": entity.get("state", "unknown"),
|
|
|
|
|
|
"current_temperature": current_temp,
|
|
|
|
|
|
"target_temperature": target_temp,
|
|
|
|
|
|
"hvac_modes": attrs.get("hvac_modes", []),
|
|
|
|
|
|
"humidity": attrs.get("current_humidity"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Sensor device_classes we care about
|
|
|
|
|
|
_SENSOR_CLASSES = {"temperature", "humidity"}
|
|
|
|
|
|
|
|
|
|
|
|
# Binary sensor device_classes we display
|
|
|
|
|
|
_BINARY_SENSOR_CLASSES = {"door", "window", "motion", "occupancy"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Fetch all entity states
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_ha_data(url: str, token: str) -> Dict[str, Any]:
|
|
|
|
|
|
"""Fetch and categorise entity states from a Home Assistant instance."""
|
|
|
|
|
|
|
2026-03-02 01:48:51 +01:00
|
|
|
|
result: Dict[str, Any] = {
|
|
|
|
|
|
"online": False,
|
|
|
|
|
|
"lights": [],
|
2026-03-02 19:30:20 +01:00
|
|
|
|
"switches": [],
|
2026-03-02 01:48:51 +01:00
|
|
|
|
"covers": [],
|
|
|
|
|
|
"sensors": [],
|
2026-03-02 19:30:20 +01:00
|
|
|
|
"binary_sensors": [],
|
|
|
|
|
|
"climate": [],
|
2026-03-02 01:48:51 +01:00
|
|
|
|
"lights_on": 0,
|
|
|
|
|
|
"lights_total": 0,
|
2026-03-02 19:30:20 +01:00
|
|
|
|
"switches_on": 0,
|
|
|
|
|
|
"switches_total": 0,
|
2026-03-02 01:48:51 +01:00
|
|
|
|
"error": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if not url or not token:
|
|
|
|
|
|
result["error"] = "Missing Home Assistant URL or token"
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
base_url = url.rstrip("/")
|
|
|
|
|
|
headers = {
|
|
|
|
|
|
"Authorization": f"Bearer {token}",
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=15, verify=False) as client:
|
|
|
|
|
|
resp = await client.get(f"{base_url}/api/states", headers=headers)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
entities: List[Dict[str, Any]] = resp.json()
|
|
|
|
|
|
except httpx.HTTPStatusError as exc:
|
|
|
|
|
|
result["error"] = f"HTTP {exc.response.status_code}"
|
|
|
|
|
|
return result
|
|
|
|
|
|
except httpx.RequestError as exc:
|
|
|
|
|
|
result["error"] = f"Connection failed: {exc}"
|
|
|
|
|
|
return result
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
result["error"] = str(exc)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
result["online"] = True
|
|
|
|
|
|
|
|
|
|
|
|
lights: List[Dict[str, Any]] = []
|
2026-03-02 19:30:20 +01:00
|
|
|
|
switches: List[Dict[str, Any]] = []
|
2026-03-02 01:48:51 +01:00
|
|
|
|
covers: List[Dict[str, Any]] = []
|
|
|
|
|
|
sensors: List[Dict[str, Any]] = []
|
2026-03-02 19:30:20 +01:00
|
|
|
|
binary_sensors: List[Dict[str, Any]] = []
|
|
|
|
|
|
climate_list: List[Dict[str, Any]] = []
|
2026-03-02 01:48:51 +01:00
|
|
|
|
|
|
|
|
|
|
for entity in entities:
|
|
|
|
|
|
entity_id: str = entity.get("entity_id", "")
|
|
|
|
|
|
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
|
|
|
|
|
attrs = entity.get("attributes", {})
|
|
|
|
|
|
state = entity.get("state", "")
|
|
|
|
|
|
|
|
|
|
|
|
if state in ("unavailable", "unknown"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if domain == "light":
|
|
|
|
|
|
lights.append(_parse_light(entity))
|
|
|
|
|
|
|
2026-03-02 19:30:20 +01:00
|
|
|
|
elif domain == "switch":
|
|
|
|
|
|
switches.append(_parse_switch(entity))
|
|
|
|
|
|
|
2026-03-02 01:48:51 +01:00
|
|
|
|
elif domain == "cover":
|
|
|
|
|
|
covers.append(_parse_cover(entity))
|
|
|
|
|
|
|
|
|
|
|
|
elif domain == "sensor":
|
|
|
|
|
|
device_class = attrs.get("device_class", "")
|
2026-03-02 19:30:20 +01:00
|
|
|
|
if device_class in _SENSOR_CLASSES:
|
2026-03-02 01:48:51 +01:00
|
|
|
|
sensors.append(_parse_sensor(entity))
|
|
|
|
|
|
|
2026-03-02 19:30:20 +01:00
|
|
|
|
elif domain == "binary_sensor":
|
|
|
|
|
|
device_class = attrs.get("device_class", "")
|
|
|
|
|
|
if device_class in _BINARY_SENSOR_CLASSES:
|
|
|
|
|
|
binary_sensors.append(_parse_binary_sensor(entity))
|
|
|
|
|
|
|
|
|
|
|
|
elif domain == "climate":
|
|
|
|
|
|
climate_list.append(_parse_climate(entity))
|
2026-03-02 01:48:51 +01:00
|
|
|
|
|
|
|
|
|
|
result["lights"] = lights
|
2026-03-02 19:30:20 +01:00
|
|
|
|
result["switches"] = switches
|
2026-03-02 01:48:51 +01:00
|
|
|
|
result["covers"] = covers
|
|
|
|
|
|
result["sensors"] = sensors
|
2026-03-02 19:30:20 +01:00
|
|
|
|
result["binary_sensors"] = binary_sensors
|
|
|
|
|
|
result["climate"] = climate_list
|
|
|
|
|
|
result["lights_on"] = sum(1 for l in lights if l["state"] == "on")
|
2026-03-02 01:48:51 +01:00
|
|
|
|
result["lights_total"] = len(lights)
|
2026-03-02 19:30:20 +01:00
|
|
|
|
result["switches_on"] = sum(1 for s in switches if s["state"] == "on")
|
|
|
|
|
|
result["switches_total"] = len(switches)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"[HA] Fetched %d lights (%d on), %d switches (%d on), %d covers, "
|
|
|
|
|
|
"%d sensors, %d binary, %d climate",
|
|
|
|
|
|
len(lights), result["lights_on"],
|
|
|
|
|
|
len(switches), result["switches_on"],
|
|
|
|
|
|
len(covers), len(sensors), len(binary_sensors), len(climate_list),
|
|
|
|
|
|
)
|
2026-03-02 01:48:51 +01:00
|
|
|
|
|
|
|
|
|
|
return result
|
2026-03-02 19:30:20 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Call HA service (control entities)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
# Map of (domain, action) → HA service name
|
|
|
|
|
|
_ACTION_MAP: Dict[tuple, str] = {
|
|
|
|
|
|
("light", "toggle"): "toggle",
|
|
|
|
|
|
("light", "turn_on"): "turn_on",
|
|
|
|
|
|
("light", "turn_off"): "turn_off",
|
|
|
|
|
|
("switch", "toggle"): "toggle",
|
|
|
|
|
|
("switch", "turn_on"): "turn_on",
|
|
|
|
|
|
("switch", "turn_off"): "turn_off",
|
|
|
|
|
|
("cover", "open"): "open_cover",
|
|
|
|
|
|
("cover", "close"): "close_cover",
|
|
|
|
|
|
("cover", "stop"): "stop_cover",
|
|
|
|
|
|
("climate", "turn_on"): "turn_on",
|
|
|
|
|
|
("climate", "turn_off"): "turn_off",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def call_ha_service(
|
|
|
|
|
|
url: str,
|
|
|
|
|
|
token: str,
|
|
|
|
|
|
entity_id: str,
|
|
|
|
|
|
action: str,
|
|
|
|
|
|
service_data: Optional[Dict[str, Any]] = None,
|
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
|
"""Call a Home Assistant service to control an entity.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
url: Base URL of the HA instance.
|
|
|
|
|
|
token: Long-lived access token.
|
|
|
|
|
|
entity_id: e.g. ``light.wohnzimmer_deckenlampe``
|
|
|
|
|
|
action: e.g. ``toggle``, ``turn_on``, ``turn_off``, ``open``, ``close``, ``stop``
|
|
|
|
|
|
service_data: Optional extra data (e.g. brightness).
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
``{"ok": True}`` on success, ``{"ok": False, "error": "..."}`` on failure.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
if "." not in entity_id:
|
|
|
|
|
|
return {"ok": False, "error": f"Invalid entity_id: {entity_id}"}
|
|
|
|
|
|
|
|
|
|
|
|
domain = entity_id.split(".")[0]
|
|
|
|
|
|
service = _ACTION_MAP.get((domain, action))
|
|
|
|
|
|
if service is None:
|
|
|
|
|
|
return {"ok": False, "error": f"Unknown action '{action}' for domain '{domain}'"}
|
|
|
|
|
|
|
|
|
|
|
|
base_url = url.rstrip("/")
|
|
|
|
|
|
headers = {
|
|
|
|
|
|
"Authorization": f"Bearer {token}",
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
payload: Dict[str, Any] = {"entity_id": entity_id}
|
|
|
|
|
|
if service_data:
|
|
|
|
|
|
payload.update(service_data)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=10, verify=False) as client:
|
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
|
f"{base_url}/api/services/{domain}/{service}",
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
json=payload,
|
|
|
|
|
|
)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
except httpx.HTTPStatusError as exc:
|
|
|
|
|
|
logger.error("[HA] Service call failed: HTTP %s for %s/%s %s",
|
|
|
|
|
|
exc.response.status_code, domain, service, entity_id)
|
|
|
|
|
|
return {"ok": False, "error": f"HTTP {exc.response.status_code}"}
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.error("[HA] Service call error: %s for %s/%s %s",
|
|
|
|
|
|
exc, domain, service, entity_id)
|
|
|
|
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
|
|
|
|
|
|
|
|
logger.info("[HA] %s/%s → %s ✓", domain, service, entity_id)
|
|
|
|
|
|
return {"ok": True}
|