daily-briefing/server/services/ha_service.py
Sam 3235e6293a Filter out Unraid infrastructure entities from HA smart-home view
Exclude Docker container power switches, system service switches,
VM switches, and hardware monitoring sensors (disk/CPU/GPU temps)
from the dashboard. Only show actual smart-home entities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 19:36:37 +01:00

343 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Home Assistant integration fetch entity states & call services."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
import httpx
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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),
"state": entity.get("state", "unknown"),
"brightness": brightness_pct,
"color_mode": attrs.get("color_mode"),
}
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"),
}
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"),
"device_class": attrs.get("device_class", ""),
}
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", ""),
}
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", ""),
}
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"}
# ---------------------------------------------------------------------------
# Filters — exclude Unraid infrastructure entities from the smart-home view.
# Unraid integration exposes Docker containers, system services, VMs,
# disk temps, CPU/GPU temps etc. as HA entities. They clutter the dashboard.
# ---------------------------------------------------------------------------
# Switches whose friendly_name starts with these are hidden
_SWITCH_EXCLUDE_PREFIXES = (
"Daddelolymp Docker:",
"Daddelolymp Service:",
"Daddelolymp VM:",
"Daddelolymp Array:",
"Moneyboy Docker:",
"Moneyboy Service:",
"Moneyboy VM:",
"Moneyboy Array:",
)
# Also exclude exact switch names that are server-level, not smart-home
_SWITCH_EXCLUDE_EXACT = {
"Daddelolymp",
"Moneyboy",
}
# Sensor friendly_names containing these substrings are hardware, not room sensors
_SENSOR_EXCLUDE_SUBSTRINGS = (
"System: CPU",
"System: Motherboard",
"Disk:",
"GPU:",
)
# ---------------------------------------------------------------------------
# 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."""
result: Dict[str, Any] = {
"online": False,
"lights": [],
"switches": [],
"covers": [],
"sensors": [],
"binary_sensors": [],
"climate": [],
"lights_on": 0,
"lights_total": 0,
"switches_on": 0,
"switches_total": 0,
"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]] = []
switches: List[Dict[str, Any]] = []
covers: List[Dict[str, Any]] = []
sensors: List[Dict[str, Any]] = []
binary_sensors: List[Dict[str, Any]] = []
climate_list: List[Dict[str, Any]] = []
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
friendly = _friendly_name(entity)
if domain == "light":
lights.append(_parse_light(entity))
elif domain == "switch":
# Skip Unraid infrastructure switches
if friendly in _SWITCH_EXCLUDE_EXACT:
continue
if any(friendly.startswith(p) for p in _SWITCH_EXCLUDE_PREFIXES):
continue
switches.append(_parse_switch(entity))
elif domain == "cover":
covers.append(_parse_cover(entity))
elif domain == "sensor":
device_class = attrs.get("device_class", "")
if device_class in _SENSOR_CLASSES:
# Skip hardware monitoring sensors (disk/CPU/GPU temps)
if any(sub in friendly for sub in _SENSOR_EXCLUDE_SUBSTRINGS):
continue
sensors.append(_parse_sensor(entity))
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))
result["lights"] = lights
result["switches"] = switches
result["covers"] = covers
result["sensors"] = sensors
result["binary_sensors"] = binary_sensors
result["climate"] = climate_list
result["lights_on"] = sum(1 for l in lights if l["state"] == "on")
result["lights_total"] = len(lights)
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),
)
return result
# ---------------------------------------------------------------------------
# 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}