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:
306
server/src/wled_controller/api/routes/home_assistant.py
Normal file
306
server/src/wled_controller/api/routes/home_assistant.py
Normal 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,
|
||||
)
|
||||
@@ -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"]),
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user