feat: Home Assistant integration — WebSocket connection, automation conditions, UI

Add full Home Assistant integration via WebSocket API:
- HARuntime: persistent WebSocket client with auth, auto-reconnect, entity state cache
- HAManager: ref-counted runtime pool (like WeatherManager)
- HomeAssistantCondition: new automation trigger type matching entity states
- REST API: CRUD for HA sources + /test, /entities, /status endpoints
- /api/v1/system/integrations-status: combined MQTT + HA dashboard indicators
- Frontend: HA Sources tab in Streams, condition type in automation editor
- Modal editor with host, token, SSL, entity filters
- websockets>=13.0 dependency added
This commit is contained in:
2026-03-27 22:42:48 +03:00
parent f3d07fc47f
commit 2153dde4b7
26 changed files with 1912 additions and 119 deletions

View File

@@ -0,0 +1,306 @@
"""Home Assistant source routes: CRUD + test + entity list + status."""
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_ha_manager,
get_ha_store,
)
from wled_controller.api.schemas.home_assistant import (
HomeAssistantConnectionStatus,
HomeAssistantEntityListResponse,
HomeAssistantEntityResponse,
HomeAssistantSourceCreate,
HomeAssistantSourceListResponse,
HomeAssistantSourceResponse,
HomeAssistantSourceUpdate,
HomeAssistantStatusResponse,
HomeAssistantTestResponse,
)
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.core.home_assistant.ha_runtime import HARuntime
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.home_assistant_source import HomeAssistantSource
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _to_response(
source: HomeAssistantSource, manager: HomeAssistantManager
) -> HomeAssistantSourceResponse:
runtime = manager.get_runtime(source.id)
return HomeAssistantSourceResponse(
id=source.id,
name=source.name,
host=source.host,
use_ssl=source.use_ssl,
entity_filters=source.entity_filters,
connected=runtime.is_connected if runtime else False,
entity_count=len(runtime.get_all_states()) if runtime else 0,
description=source.description,
tags=source.tags,
created_at=source.created_at,
updated_at=source.updated_at,
)
@router.get(
"/api/v1/home-assistant/sources",
response_model=HomeAssistantSourceListResponse,
tags=["Home Assistant"],
)
async def list_ha_sources(
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
sources = store.get_all_sources()
return HomeAssistantSourceListResponse(
sources=[_to_response(s, manager) for s in sources],
count=len(sources),
)
@router.post(
"/api/v1/home-assistant/sources",
response_model=HomeAssistantSourceResponse,
status_code=201,
tags=["Home Assistant"],
)
async def create_ha_source(
data: HomeAssistantSourceCreate,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
source = store.create_source(
name=data.name,
host=data.host,
token=data.token,
use_ssl=data.use_ssl,
entity_filters=data.entity_filters,
description=data.description,
tags=data.tags,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("home_assistant_source", "created", source.id)
return _to_response(source, manager)
@router.get(
"/api/v1/home-assistant/sources/{source_id}",
response_model=HomeAssistantSourceResponse,
tags=["Home Assistant"],
)
async def get_ha_source(
source_id: str,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
source = store.get_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
return _to_response(source, manager)
@router.put(
"/api/v1/home-assistant/sources/{source_id}",
response_model=HomeAssistantSourceResponse,
tags=["Home Assistant"],
)
async def update_ha_source(
source_id: str,
data: HomeAssistantSourceUpdate,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
source = store.update_source(
source_id,
name=data.name,
host=data.host,
token=data.token,
use_ssl=data.use_ssl,
entity_filters=data.entity_filters,
description=data.description,
tags=data.tags,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await manager.update_source(source_id)
fire_entity_event("home_assistant_source", "updated", source.id)
return _to_response(source, manager)
@router.delete(
"/api/v1/home-assistant/sources/{source_id}", status_code=204, tags=["Home Assistant"]
)
async def delete_ha_source(
source_id: str,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
store.delete_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
# Release any active runtime
await manager.release(source_id)
fire_entity_event("home_assistant_source", "deleted", source_id)
@router.get(
"/api/v1/home-assistant/sources/{source_id}/entities",
response_model=HomeAssistantEntityListResponse,
tags=["Home Assistant"],
)
async def list_ha_entities(
source_id: str,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
"""List available entities from a HA instance (live query)."""
try:
source = store.get_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
# Try cached states first from running runtime
runtime = manager.get_runtime(source_id)
if runtime and runtime.is_connected:
states = runtime.get_all_states()
entities = [
HomeAssistantEntityResponse(
entity_id=s.entity_id,
state=s.state,
friendly_name=s.attributes.get("friendly_name", s.entity_id),
domain=s.entity_id.split(".")[0] if "." in s.entity_id else "",
)
for s in states.values()
]
return HomeAssistantEntityListResponse(entities=entities, count=len(entities))
# No active runtime — do a one-shot fetch
temp_runtime = HARuntime(source)
try:
raw_entities = await temp_runtime.fetch_entities()
finally:
await temp_runtime.stop()
entities = [HomeAssistantEntityResponse(**e) for e in raw_entities]
return HomeAssistantEntityListResponse(entities=entities, count=len(entities))
@router.post(
"/api/v1/home-assistant/sources/{source_id}/test",
response_model=HomeAssistantTestResponse,
tags=["Home Assistant"],
)
async def test_ha_source(
source_id: str,
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
):
"""Test connection to a Home Assistant instance."""
try:
source = store.get_source(source_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
try:
import websockets
except ImportError:
return HomeAssistantTestResponse(
success=False,
error="websockets package not installed",
)
try:
async with websockets.connect(source.ws_url) as ws:
# Wait for auth_required
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
if msg.get("type") != "auth_required":
return HomeAssistantTestResponse(
success=False, error=f"Unexpected message: {msg.get('type')}"
)
# Auth
await ws.send(json.dumps({"type": "auth", "access_token": source.token}))
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
if msg.get("type") != "auth_ok":
return HomeAssistantTestResponse(
success=False, error=msg.get("message", "Auth failed")
)
ha_version = msg.get("ha_version")
# Get entity count
await ws.send(json.dumps({"id": 1, "type": "get_states"}))
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
entity_count = len(msg.get("result", [])) if msg.get("success") else 0
return HomeAssistantTestResponse(
success=True,
ha_version=ha_version,
entity_count=entity_count,
)
except Exception as e:
return HomeAssistantTestResponse(success=False, error=str(e))
@router.get(
"/api/v1/home-assistant/status",
response_model=HomeAssistantStatusResponse,
tags=["Home Assistant"],
)
async def get_ha_status(
_auth: AuthRequired,
store: HomeAssistantStore = Depends(get_ha_store),
manager: HomeAssistantManager = Depends(get_ha_manager),
):
"""Get overall HA integration status (for dashboard indicators)."""
all_sources = store.get_all_sources()
conn_statuses = manager.get_connection_status()
# Build a map for quick lookup
status_map = {s["source_id"]: s for s in conn_statuses}
connections = []
connected_count = 0
for source in all_sources:
status = status_map.get(source.id)
connected = status["connected"] if status else False
if connected:
connected_count += 1
connections.append(
HomeAssistantConnectionStatus(
source_id=source.id,
name=source.name,
connected=connected,
entity_count=status["entity_count"] if status else 0,
)
)
return HomeAssistantStatusResponse(
connections=connections,
total_sources=len(all_sources),
connected_count=connected_count,
)

View File

@@ -21,6 +21,8 @@ from wled_controller.api.dependencies import (
get_automation_store,
get_color_strip_store,
get_device_store,
get_ha_manager,
get_ha_store,
get_output_target_store,
get_pattern_template_store,
get_picture_source_store,
@@ -311,3 +313,52 @@ def list_api_keys(_: AuthRequired):
for label, key in config.auth.api_keys.items()
]
return {"keys": keys, "count": len(keys)}
@router.get("/api/v1/system/integrations-status", tags=["System"])
async def get_integrations_status(
_: AuthRequired,
ha_store=Depends(get_ha_store),
ha_manager=Depends(get_ha_manager),
):
"""Return connection status for external integrations (MQTT, Home Assistant).
Used by the dashboard to show connectivity indicators.
"""
from wled_controller.core.devices.mqtt_client import get_mqtt_service
# MQTT status
mqtt_service = get_mqtt_service()
mqtt_config = get_config().mqtt
mqtt_status = {
"enabled": mqtt_config.enabled,
"connected": mqtt_service.is_connected if mqtt_service else False,
"broker": (
f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None
),
}
# Home Assistant status
ha_sources = ha_store.get_all_sources()
ha_connections = ha_manager.get_connection_status()
ha_status_map = {s["source_id"]: s for s in ha_connections}
ha_items = []
for source in ha_sources:
status = ha_status_map.get(source.id)
ha_items.append(
{
"source_id": source.id,
"name": source.name,
"connected": status["connected"] if status else False,
"entity_count": status["entity_count"] if status else 0,
}
)
return {
"mqtt": mqtt_status,
"home_assistant": {
"sources": ha_items,
"total": len(ha_sources),
"connected": sum(1 for s in ha_items if s["connected"]),
},
}