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
@@ -27,6 +27,7 @@ from .routes.gradients import router as gradients_router
from .routes.weather_sources import router as weather_sources_router
from .routes.update import router as update_router
from .routes.assets import router as assets_router
from .routes.home_assistant import router as home_assistant_router
router = APIRouter()
router.include_router(system_router)
@@ -54,5 +55,6 @@ router.include_router(gradients_router)
router.include_router(weather_sources_router)
router.include_router(update_router)
router.include_router(assets_router)
router.include_router(home_assistant_router)
__all__ = ["router"]
+53 -33
View File
@@ -21,7 +21,9 @@ from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
)
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.storage.asset_store import AssetStore
@@ -30,6 +32,8 @@ from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.update.update_service import UpdateService
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
T = TypeVar("T")
@@ -136,6 +140,14 @@ def get_asset_store() -> AssetStore:
return _get("asset_store", "Asset store")
def get_ha_store() -> HomeAssistantStore:
return _get("ha_store", "Home Assistant store")
def get_ha_manager() -> HomeAssistantManager:
return _get("ha_manager", "Home Assistant manager")
def get_database() -> Database:
return _get("database", "Database")
@@ -157,12 +169,14 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""
pm = _deps.get("processor_manager")
if pm is not None:
pm.fire_event({
"type": "entity_changed",
"entity_type": entity_type,
"action": action,
"id": entity_id,
})
pm.fire_event(
{
"type": "entity_changed",
"entity_type": entity_type,
"action": action,
"id": entity_id,
}
)
# ── Initialization ──────────────────────────────────────────────────────
@@ -193,31 +207,37 @@ def init_dependencies(
weather_manager: WeatherManager | None = None,
update_service: UpdateService | None = None,
asset_store: AssetStore | None = None,
ha_store: HomeAssistantStore | None = None,
ha_manager: HomeAssistantManager | None = None,
):
"""Initialize global dependencies."""
_deps.update({
"database": database,
"device_store": device_store,
"template_store": template_store,
"processor_manager": processor_manager,
"pp_template_store": pp_template_store,
"pattern_template_store": pattern_template_store,
"picture_source_store": picture_source_store,
"output_target_store": output_target_store,
"color_strip_store": color_strip_store,
"audio_source_store": audio_source_store,
"audio_template_store": audio_template_store,
"value_source_store": value_source_store,
"automation_store": automation_store,
"scene_preset_store": scene_preset_store,
"automation_engine": automation_engine,
"auto_backup_engine": auto_backup_engine,
"sync_clock_store": sync_clock_store,
"sync_clock_manager": sync_clock_manager,
"cspt_store": cspt_store,
"gradient_store": gradient_store,
"weather_source_store": weather_source_store,
"weather_manager": weather_manager,
"update_service": update_service,
"asset_store": asset_store,
})
_deps.update(
{
"database": database,
"device_store": device_store,
"template_store": template_store,
"processor_manager": processor_manager,
"pp_template_store": pp_template_store,
"pattern_template_store": pattern_template_store,
"picture_source_store": picture_source_store,
"output_target_store": output_target_store,
"color_strip_store": color_strip_store,
"audio_source_store": audio_source_store,
"audio_template_store": audio_template_store,
"value_source_store": value_source_store,
"automation_store": automation_store,
"scene_preset_store": scene_preset_store,
"automation_engine": automation_engine,
"auto_backup_engine": auto_backup_engine,
"sync_clock_store": sync_clock_store,
"sync_clock_manager": sync_clock_manager,
"cspt_store": cspt_store,
"gradient_store": gradient_store,
"weather_source_store": weather_source_store,
"weather_manager": weather_manager,
"update_service": update_service,
"asset_store": asset_store,
"ha_store": ha_store,
"ha_manager": ha_manager,
}
)
@@ -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"]),
},
}
@@ -12,21 +12,41 @@ class ConditionSchema(BaseModel):
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
# Application condition fields
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
match_type: Optional[str] = Field(
None, description="'running' or 'topmost' (for application condition)"
)
# Time-of-day condition fields
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day condition)")
start_time: Optional[str] = Field(
None, description="Start time HH:MM (for time_of_day condition)"
)
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
# System idle condition fields
idle_minutes: Optional[int] = Field(None, description="Idle timeout in minutes (for system_idle condition)")
when_idle: Optional[bool] = Field(None, description="True=active when idle (for system_idle condition)")
idle_minutes: Optional[int] = Field(
None, description="Idle timeout in minutes (for system_idle condition)"
)
when_idle: Optional[bool] = Field(
None, description="True=active when idle (for system_idle condition)"
)
# Display state condition fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
# MQTT condition fields
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)")
match_mode: Optional[str] = Field(
None, description="'exact', 'contains', or 'regex' (for mqtt condition)"
)
# Webhook condition fields
token: Optional[str] = Field(None, description="Secret token for webhook URL (for webhook condition)")
token: Optional[str] = Field(
None, description="Secret token for webhook URL (for webhook condition)"
)
# Home Assistant condition fields
ha_source_id: Optional[str] = Field(
None, description="Home Assistant source ID (for home_assistant condition)"
)
entity_id: Optional[str] = Field(
None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant condition)",
)
class AutomationCreate(BaseModel):
@@ -35,10 +55,16 @@ class AutomationCreate(BaseModel):
name: str = Field(description="Automation name", min_length=1, max_length=100)
enabled: bool = Field(default=True, description="Whether the automation is enabled")
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
conditions: List[ConditionSchema] = Field(
default_factory=list, description="List of conditions"
)
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
deactivation_mode: str = Field(
default="none", description="'none', 'revert', or 'fallback_scene'"
)
deactivation_scene_preset_id: Optional[str] = Field(
None, description="Scene preset for fallback deactivation"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -47,11 +73,17 @@ class AutomationUpdate(BaseModel):
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
condition_logic: Optional[str] = Field(
None, description="How conditions combine: 'or' or 'and'"
)
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: Optional[str] = Field(None, description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
deactivation_mode: Optional[str] = Field(
None, description="'none', 'revert', or 'fallback_scene'"
)
deactivation_scene_preset_id: Optional[str] = Field(
None, description="Scene preset for fallback deactivation"
)
tags: Optional[List[str]] = None
@@ -67,10 +99,16 @@ class AutomationResponse(BaseModel):
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
webhook_url: Optional[str] = Field(None, description="Webhook URL for the first webhook condition (if any)")
webhook_url: Optional[str] = Field(
None, description="Webhook URL for the first webhook condition (if any)"
)
is_active: bool = Field(default=False, description="Whether the automation is currently active")
last_activated_at: Optional[datetime] = Field(None, description="Last time this automation was activated")
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this automation was deactivated")
last_activated_at: Optional[datetime] = Field(
None, description="Last time this automation was activated"
)
last_deactivated_at: Optional[datetime] = Field(
None, description="Last time this automation was deactivated"
)
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -0,0 +1,97 @@
"""Home Assistant source schemas (CRUD + test + entities)."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class HomeAssistantSourceCreate(BaseModel):
"""Request to create a Home Assistant source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
host: str = Field(description="HA host:port (e.g. '192.168.1.100:8123')", min_length=1)
token: str = Field(description="Long-Lived Access Token", min_length=1)
use_ssl: bool = Field(default=False, description="Use wss:// instead of ws://")
entity_filters: List[str] = Field(
default_factory=list, description="Entity ID filter patterns (e.g. ['sensor.*'])"
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class HomeAssistantSourceUpdate(BaseModel):
"""Request to update a Home Assistant source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
host: Optional[str] = Field(None, description="HA host:port", min_length=1)
token: Optional[str] = Field(None, description="Long-Lived Access Token", min_length=1)
use_ssl: Optional[bool] = Field(None, description="Use wss://")
entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
class HomeAssistantSourceResponse(BaseModel):
"""Home Assistant source response."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
host: str = Field(description="HA host:port")
use_ssl: bool = Field(description="Whether SSL is enabled")
entity_filters: List[str] = Field(default_factory=list, description="Entity filter patterns")
connected: bool = Field(default=False, description="Whether the WebSocket connection is active")
entity_count: int = Field(default=0, description="Number of cached entities")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class HomeAssistantSourceListResponse(BaseModel):
"""List of Home Assistant sources."""
sources: List[HomeAssistantSourceResponse] = Field(description="List of HA sources")
count: int = Field(description="Number of sources")
class HomeAssistantEntityResponse(BaseModel):
"""A single HA entity."""
entity_id: str = Field(description="Entity ID (e.g. 'sensor.temperature')")
state: str = Field(description="Current state value")
friendly_name: str = Field(description="Human-readable name")
domain: str = Field(description="Entity domain (e.g. 'sensor', 'binary_sensor')")
class HomeAssistantEntityListResponse(BaseModel):
"""List of entities from a HA instance."""
entities: List[HomeAssistantEntityResponse] = Field(description="List of entities")
count: int = Field(description="Number of entities")
class HomeAssistantTestResponse(BaseModel):
"""Connection test result."""
success: bool = Field(description="Whether connection and auth succeeded")
ha_version: Optional[str] = Field(None, description="Home Assistant version")
entity_count: int = Field(default=0, description="Number of entities found")
error: Optional[str] = Field(None, description="Error message if connection failed")
class HomeAssistantConnectionStatus(BaseModel):
"""Connection status for dashboard indicators."""
source_id: str
name: str
connected: bool
entity_count: int
class HomeAssistantStatusResponse(BaseModel):
"""Overall HA integration status for dashboard."""
connections: List[HomeAssistantConnectionStatus]
total_sources: int
connected_count: int