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:
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user