diff --git a/TODO-css-improvements.md b/TODO-css-improvements.md index f741250..8b76e40 100644 --- a/TODO-css-improvements.md +++ b/TODO-css-improvements.md @@ -111,5 +111,6 @@ Needs deeper design discussion. Likely a new entity type `ColorStripSourceTransi ## Remaining Open Discussion -1. **`home_assistant` source** — Need to research HAOS communication protocols first +1. ~~**`home_assistant` source** — Need to research HAOS communication protocols first~~ **DONE** — WebSocket API chosen, connection layer + automation condition + UI implemented 2. **Transition engine** — Design as `ColorStripSourceTransition` entity: what transition types? (crossfade, wipe, dissolve?) How does a target reference its transition config? How do automations trigger it? +3. **Home Assistant output targets** — Investigate casting LED colors TO Home Assistant lights (reverse direction). Use HA `light.turn_on` service call with `rgb_color` via WebSocket API. Could enable: ambient lighting on HA-controlled bulbs (Hue, WLED via HA, Zigbee lights), room-by-room color sync, whole-home ambient scenes. Need to research: rate limiting (don't spam HA with 30fps updates), grouping multiple lights, brightness/color_temp mapping, transition parameter support. diff --git a/server/pyproject.toml b/server/pyproject.toml index 7fbebdc..910924b 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "aiomqtt>=2.0.0", "openrgb-python>=0.2.15", "opencv-python-headless>=4.8.0", + "websockets>=13.0", ] [project.optional-dependencies] diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 3e5e643..33875cb 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -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"] diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index f0fdcb8..ad22616 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -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, + } + ) diff --git a/server/src/wled_controller/api/routes/home_assistant.py b/server/src/wled_controller/api/routes/home_assistant.py new file mode 100644 index 0000000..6b30503 --- /dev/null +++ b/server/src/wled_controller/api/routes/home_assistant.py @@ -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, + ) diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index c195882..4c01ed2 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -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"]), + }, + } diff --git a/server/src/wled_controller/api/schemas/automations.py b/server/src/wled_controller/api/schemas/automations.py index 5c47e51..f75cbd8 100644 --- a/server/src/wled_controller/api/schemas/automations.py +++ b/server/src/wled_controller/api/schemas/automations.py @@ -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") diff --git a/server/src/wled_controller/api/schemas/home_assistant.py b/server/src/wled_controller/api/schemas/home_assistant.py new file mode 100644 index 0000000..b33e277 --- /dev/null +++ b/server/src/wled_controller/api/schemas/home_assistant.py @@ -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 diff --git a/server/src/wled_controller/core/automations/automation_engine.py b/server/src/wled_controller/core/automations/automation_engine.py index e389eba..c951934 100644 --- a/server/src/wled_controller/core/automations/automation_engine.py +++ b/server/src/wled_controller/core/automations/automation_engine.py @@ -12,6 +12,7 @@ from wled_controller.storage.automation import ( Automation, Condition, DisplayStateCondition, + HomeAssistantCondition, MQTTCondition, StartupCondition, SystemIdleCondition, @@ -37,6 +38,7 @@ class AutomationEngine: scene_preset_store=None, target_store=None, device_store=None, + ha_manager=None, ): self._store = automation_store self._manager = processor_manager @@ -46,6 +48,7 @@ class AutomationEngine: self._scene_preset_store = scene_preset_store self._target_store = target_store self._device_store = device_store + self._ha_manager = ha_manager self._task: Optional[asyncio.Task] = None self._eval_lock = asyncio.Lock() @@ -101,8 +104,12 @@ class AutomationEngine: await self._evaluate_all_locked() def _detect_all_sync( - self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool, - needs_idle: bool, needs_display_state: bool, + self, + needs_running: bool, + needs_topmost: bool, + needs_fullscreen: bool, + needs_idle: bool, + needs_display_state: bool, ) -> tuple: """Run all platform detection in a single thread call. @@ -115,10 +122,21 @@ class AutomationEngine: topmost_proc, topmost_fullscreen = self._detector._get_topmost_process_sync() else: topmost_proc, topmost_fullscreen = None, False - fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set() + fullscreen_procs = ( + self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set() + ) idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None - display_state = self._detector._get_display_power_state_sync() if needs_display_state else None - return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state + display_state = ( + self._detector._get_display_power_state_sync() if needs_display_state else None + ) + return ( + running_procs, + topmost_proc, + topmost_fullscreen, + fullscreen_procs, + idle_seconds, + display_state, + ) async def _evaluate_all_locked(self) -> None: automations = self._store.get_all_automations() @@ -148,24 +166,37 @@ class AutomationEngine: # Single executor call for all platform detection loop = asyncio.get_event_loop() - (running_procs, topmost_proc, topmost_fullscreen, - fullscreen_procs, idle_seconds, display_state) = ( - await loop.run_in_executor( - None, self._detect_all_sync, - needs_running, needs_topmost, needs_fullscreen, - needs_idle, needs_display_state, - ) + ( + running_procs, + topmost_proc, + topmost_fullscreen, + fullscreen_procs, + idle_seconds, + display_state, + ) = await loop.run_in_executor( + None, + self._detect_all_sync, + needs_running, + needs_topmost, + needs_fullscreen, + needs_idle, + needs_display_state, ) active_automation_ids = set() for automation in automations: - should_be_active = ( - automation.enabled - and (len(automation.conditions) == 0 - or self._evaluate_conditions( - automation, running_procs, topmost_proc, topmost_fullscreen, - fullscreen_procs, idle_seconds, display_state)) + should_be_active = automation.enabled and ( + len(automation.conditions) == 0 + or self._evaluate_conditions( + automation, + running_procs, + topmost_proc, + topmost_fullscreen, + fullscreen_procs, + idle_seconds, + display_state, + ) ) is_active = automation.id in self._active_automations @@ -184,15 +215,24 @@ class AutomationEngine: await self._deactivate_automation(aid) def _evaluate_conditions( - self, automation: Automation, running_procs: Set[str], - topmost_proc: Optional[str], topmost_fullscreen: bool, + self, + automation: Automation, + running_procs: Set[str], + topmost_proc: Optional[str], + topmost_fullscreen: bool, fullscreen_procs: Set[str], - idle_seconds: Optional[float], display_state: Optional[str], + idle_seconds: Optional[float], + display_state: Optional[str], ) -> bool: results = [ self._evaluate_condition( - c, running_procs, topmost_proc, topmost_fullscreen, - fullscreen_procs, idle_seconds, display_state, + c, + running_procs, + topmost_proc, + topmost_fullscreen, + fullscreen_procs, + idle_seconds, + display_state, ) for c in automation.conditions ] @@ -202,20 +242,27 @@ class AutomationEngine: return any(results) # "or" is default def _evaluate_condition( - self, condition: Condition, running_procs: Set[str], - topmost_proc: Optional[str], topmost_fullscreen: bool, + self, + condition: Condition, + running_procs: Set[str], + topmost_proc: Optional[str], + topmost_fullscreen: bool, fullscreen_procs: Set[str], - idle_seconds: Optional[float], display_state: Optional[str], + idle_seconds: Optional[float], + display_state: Optional[str], ) -> bool: dispatch = { AlwaysCondition: lambda c: True, StartupCondition: lambda c: True, - ApplicationCondition: lambda c: self._evaluate_app_condition(c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs), + ApplicationCondition: lambda c: self._evaluate_app_condition( + c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs + ), TimeOfDayCondition: lambda c: self._evaluate_time_of_day(c), SystemIdleCondition: lambda c: self._evaluate_idle(c, idle_seconds), DisplayStateCondition: lambda c: self._evaluate_display_state(c, display_state), MQTTCondition: lambda c: self._evaluate_mqtt(c), WebhookCondition: lambda c: self._webhook_states.get(c.token, False), + HomeAssistantCondition: lambda c: self._evaluate_home_assistant(c), } handler = dispatch.get(type(condition)) if handler is None: @@ -243,7 +290,9 @@ class AutomationEngine: return is_idle if condition.when_idle else not is_idle @staticmethod - def _evaluate_display_state(condition: DisplayStateCondition, display_state: Optional[str]) -> bool: + def _evaluate_display_state( + condition: DisplayStateCondition, display_state: Optional[str] + ) -> bool: if display_state is None: return False return display_state == condition.state @@ -268,6 +317,27 @@ class AutomationEngine: logger.debug("MQTT condition regex error: %s", e) return False + def _evaluate_home_assistant(self, condition: HomeAssistantCondition) -> bool: + if self._ha_manager is None: + return False + entity_state = self._ha_manager.get_state(condition.ha_source_id, condition.entity_id) + if entity_state is None: + return False + value = entity_state.state + matchers = { + "exact": lambda: value == condition.state, + "contains": lambda: condition.state in value, + "regex": lambda: bool(re.search(condition.state, value)), + } + matcher = matchers.get(condition.match_mode) + if matcher is None: + return False + try: + return matcher() + except re.error as e: + logger.debug("HA condition regex error: %s", e) + return False + def _evaluate_app_condition( self, condition: ApplicationCondition, @@ -289,8 +359,7 @@ class AutomationEngine: and any(app == topmost_proc for app in apps_lower) ), "topmost": lambda: ( - topmost_proc is not None - and any(app == topmost_proc for app in apps_lower) + topmost_proc is not None and any(app == topmost_proc for app in apps_lower) ), } handler = match_handlers.get(condition.match_type) @@ -316,12 +385,15 @@ class AutomationEngine: try: preset = self._scene_preset_store.get_preset(automation.scene_preset_id) except ValueError: - logger.warning(f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found") + logger.warning( + f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found" + ) return # For "revert" mode, capture current state before activating if automation.deactivation_mode == "revert": from wled_controller.core.scenes.scene_activator import capture_current_snapshot + targets = capture_current_snapshot(self._target_store, self._manager) self._pre_activation_snapshots[automation.id] = ScenePreset( id=f"_revert_{automation.id}", @@ -331,8 +403,11 @@ class AutomationEngine: # Apply the scene from wled_controller.core.scenes.scene_activator import apply_scene_state + status, errors = await apply_scene_state( - preset, self._target_store, self._manager, + preset, + self._target_store, + self._manager, ) self._active_automations[automation.id] = True @@ -374,8 +449,11 @@ class AutomationEngine: snapshot = self._pre_activation_snapshots.pop(automation_id, None) if snapshot and self._target_store: from wled_controller.core.scenes.scene_activator import apply_scene_state + status, errors = await apply_scene_state( - snapshot, self._target_store, self._manager, + snapshot, + self._target_store, + self._manager, ) if errors: logger.warning(f"Automation {automation_id} revert errors: {errors}") @@ -391,25 +469,34 @@ class AutomationEngine: try: fallback = self._scene_preset_store.get_preset(fallback_id) from wled_controller.core.scenes.scene_activator import apply_scene_state + status, errors = await apply_scene_state( - fallback, self._target_store, self._manager, + fallback, + self._target_store, + self._manager, ) if errors: logger.warning(f"Automation {automation_id} fallback errors: {errors}") else: - logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)") + logger.info( + f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)" + ) except ValueError: - logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found") + logger.warning( + f"Automation {automation_id}: fallback scene {fallback_id} not found" + ) else: logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)") def _fire_event(self, automation_id: str, action: str) -> None: try: - self._manager.fire_event({ - "type": "automation_state_changed", - "automation_id": automation_id, - "action": action, - }) + self._manager.fire_event( + { + "type": "automation_state_changed", + "automation_id": automation_id, + "action": action, + } + ) except Exception as e: logger.error("Automation action failed: %s", e, exc_info=True) diff --git a/server/src/wled_controller/core/home_assistant/__init__.py b/server/src/wled_controller/core/home_assistant/__init__.py new file mode 100644 index 0000000..dcece81 --- /dev/null +++ b/server/src/wled_controller/core/home_assistant/__init__.py @@ -0,0 +1 @@ +"""Home Assistant integration — WebSocket client, entity state cache, manager.""" diff --git a/server/src/wled_controller/core/home_assistant/ha_manager.py b/server/src/wled_controller/core/home_assistant/ha_manager.py new file mode 100644 index 0000000..85c2a2a --- /dev/null +++ b/server/src/wled_controller/core/home_assistant/ha_manager.py @@ -0,0 +1,128 @@ +"""Home Assistant runtime manager — ref-counted pool of HA WebSocket connections. + +Follows the WeatherManager pattern: multiple consumers (CSS streams, automation +conditions) sharing the same WebSocket connection per HA instance. +""" + +import asyncio +from typing import Any, Dict, List, Optional + +from wled_controller.core.home_assistant.ha_runtime import HAEntityState, HARuntime +from wled_controller.storage.home_assistant_store import HomeAssistantStore +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class HomeAssistantManager: + """Ref-counted pool of Home Assistant runtimes. + + Each HA source gets at most one runtime (WebSocket connection). + Multiple consumers share the same runtime via acquire/release. + """ + + def __init__(self, store: HomeAssistantStore) -> None: + self._store = store + # source_id -> (runtime, ref_count) + self._runtimes: Dict[str, tuple] = {} + self._lock = asyncio.Lock() + + async def acquire(self, source_id: str) -> HARuntime: + """Get or create a runtime for the given HA source. Increments ref count.""" + async with self._lock: + if source_id in self._runtimes: + runtime, count = self._runtimes[source_id] + self._runtimes[source_id] = (runtime, count + 1) + return runtime + + source = self._store.get(source_id) + runtime = HARuntime(source) + await runtime.start() + self._runtimes[source_id] = (runtime, 1) + return runtime + + async def release(self, source_id: str) -> None: + """Decrement ref count; stop runtime when it reaches zero.""" + async with self._lock: + if source_id not in self._runtimes: + return + runtime, count = self._runtimes[source_id] + if count <= 1: + await runtime.stop() + del self._runtimes[source_id] + else: + self._runtimes[source_id] = (runtime, count - 1) + + def get_state(self, source_id: str, entity_id: str) -> Optional[HAEntityState]: + """Get cached entity state from a running runtime (synchronous).""" + entry = self._runtimes.get(source_id) + if entry is None: + return None + runtime, _count = entry + return runtime.get_state(entity_id) + + def get_runtime(self, source_id: str) -> Optional[HARuntime]: + """Get a running runtime without changing ref count (for read-only access).""" + entry = self._runtimes.get(source_id) + if entry is None: + return None + runtime, _count = entry + return runtime + + async def ensure_runtime(self, source_id: str) -> HARuntime: + """Get or create a runtime (for API endpoints that need a connection).""" + async with self._lock: + if source_id in self._runtimes: + runtime, count = self._runtimes[source_id] + return runtime + + source = self._store.get(source_id) + runtime = HARuntime(source) + await runtime.start() + async with self._lock: + if source_id not in self._runtimes: + self._runtimes[source_id] = (runtime, 0) + else: + await runtime.stop() + runtime, _count = self._runtimes[source_id] + return runtime + + async def update_source(self, source_id: str) -> None: + """Hot-update runtime config when the source is edited.""" + entry = self._runtimes.get(source_id) + if entry is None: + return + runtime, _count = entry + try: + source = self._store.get(source_id) + runtime.update_config(source) + except Exception as e: + logger.warning(f"Failed to update HA runtime {source_id}: {e}") + + def get_connection_status(self) -> List[Dict[str, Any]]: + """Get status of all active HA connections (for dashboard indicators).""" + result = [] + for source_id, (runtime, ref_count) in self._runtimes.items(): + try: + source = self._store.get(source_id) + name = source.name + except Exception: + name = source_id + result.append( + { + "source_id": source_id, + "name": name, + "connected": runtime.is_connected, + "ref_count": ref_count, + "entity_count": len(runtime.get_all_states()), + } + ) + return result + + async def shutdown(self) -> None: + """Stop all runtimes.""" + async with self._lock: + for source_id, (runtime, _count) in list(self._runtimes.items()): + await runtime.stop() + self._runtimes.clear() + logger.info("Home Assistant manager shut down") diff --git a/server/src/wled_controller/core/home_assistant/ha_runtime.py b/server/src/wled_controller/core/home_assistant/ha_runtime.py new file mode 100644 index 0000000..38cda87 --- /dev/null +++ b/server/src/wled_controller/core/home_assistant/ha_runtime.py @@ -0,0 +1,309 @@ +"""Home Assistant WebSocket runtime — maintains a persistent connection and caches entity states. + +Follows the MQTT service pattern: async background task with auto-reconnect. +Entity state cache is thread-safe for synchronous reads (automation engine). +""" + +import asyncio +import json +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Set + +from wled_controller.storage.home_assistant_source import HomeAssistantSource +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +@dataclass(frozen=True) +class HAEntityState: + """Immutable snapshot of a Home Assistant entity state.""" + + entity_id: str + state: str # e.g. "23.5", "on", "off", "unavailable" + attributes: Dict[str, Any] = field(default_factory=dict) + last_changed: str = "" # ISO timestamp from HA + fetched_at: float = 0.0 # time.monotonic() + + +class HARuntime: + """Persistent WebSocket connection to a Home Assistant instance. + + - Authenticates via Long-Lived Access Token + - Subscribes to state_changed events + - Caches entity states for synchronous reads + - Auto-reconnects on connection loss + """ + + _RECONNECT_DELAY = 5.0 # seconds between reconnect attempts + + def __init__(self, source: HomeAssistantSource) -> None: + self._source_id = source.id + self._ws_url = source.ws_url + self._token = source.token + self._entity_filters: List[str] = list(source.entity_filters) + + # Entity state cache (thread-safe) + self._states: Dict[str, HAEntityState] = {} + self._lock = threading.Lock() + + # Callbacks: entity_id -> set of callbacks + self._callbacks: Dict[str, Set[Callable]] = {} + + # Async task management + self._task: Optional[asyncio.Task] = None + self._connected = False + self._msg_id = 0 + + @property + def is_connected(self) -> bool: + return self._connected + + @property + def source_id(self) -> str: + return self._source_id + + def get_state(self, entity_id: str) -> Optional[HAEntityState]: + """Get cached entity state (synchronous, thread-safe).""" + with self._lock: + return self._states.get(entity_id) + + def get_all_states(self) -> Dict[str, HAEntityState]: + """Get all cached entity states (synchronous, thread-safe).""" + with self._lock: + return dict(self._states) + + def subscribe(self, entity_id: str, callback: Callable) -> None: + """Register a callback for entity state changes.""" + if entity_id not in self._callbacks: + self._callbacks[entity_id] = set() + self._callbacks[entity_id].add(callback) + + def unsubscribe(self, entity_id: str, callback: Callable) -> None: + """Remove a callback for entity state changes.""" + if entity_id in self._callbacks: + self._callbacks[entity_id].discard(callback) + if not self._callbacks[entity_id]: + del self._callbacks[entity_id] + + async def start(self) -> None: + """Start the WebSocket connection loop.""" + if self._task is not None: + return + self._task = asyncio.create_task(self._connection_loop()) + logger.info(f"HA runtime started: {self._source_id} -> {self._ws_url}") + + async def stop(self) -> None: + """Stop the WebSocket connection.""" + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self._connected = False + logger.info(f"HA runtime stopped: {self._source_id}") + + def update_config(self, source: HomeAssistantSource) -> None: + """Hot-update config (token, filters). Connection will use new values on next reconnect.""" + self._ws_url = source.ws_url + self._token = source.token + self._entity_filters = list(source.entity_filters) + + def _next_id(self) -> int: + self._msg_id += 1 + return self._msg_id + + def _matches_filter(self, entity_id: str) -> bool: + """Check if an entity matches the configured filters (empty = allow all).""" + if not self._entity_filters: + return True + import fnmatch + + return any(fnmatch.fnmatch(entity_id, pattern) for pattern in self._entity_filters) + + async def _connection_loop(self) -> None: + """Persistent connection with auto-reconnect.""" + try: + import websockets + except ImportError: + logger.error( + "websockets package not installed — Home Assistant integration unavailable" + ) + return + + while True: + try: + async with websockets.connect(self._ws_url) as ws: + # Step 1: Wait for auth_required + msg = json.loads(await ws.recv()) + if msg.get("type") != "auth_required": + logger.warning(f"HA unexpected initial message: {msg.get('type')}") + continue + + # Step 2: Send auth + await ws.send( + json.dumps( + { + "type": "auth", + "access_token": self._token, + } + ) + ) + + msg = json.loads(await ws.recv()) + if msg.get("type") != "auth_ok": + logger.error(f"HA auth failed: {msg.get('message', 'unknown error')}") + await asyncio.sleep(self._RECONNECT_DELAY) + continue + + self._connected = True + logger.info( + f"HA connected: {self._source_id} (version {msg.get('ha_version', '?')})" + ) + + # Step 3: Fetch all current states + fetch_id = self._next_id() + await ws.send( + json.dumps( + { + "id": fetch_id, + "type": "get_states", + } + ) + ) + + # Step 4: Subscribe to state_changed events + sub_id = self._next_id() + await ws.send( + json.dumps( + { + "id": sub_id, + "type": "subscribe_events", + "event_type": "state_changed", + } + ) + ) + + # Step 5: Message loop + async for raw in ws: + msg = json.loads(raw) + msg_type = msg.get("type") + + if msg_type == "result" and msg.get("id") == fetch_id: + # Initial state dump + self._handle_state_dump(msg.get("result", [])) + + elif msg_type == "event": + event = msg.get("event", {}) + if event.get("event_type") == "state_changed": + self._handle_state_changed(event.get("data", {})) + + except asyncio.CancelledError: + break + except Exception as e: + self._connected = False + logger.warning( + f"HA connection lost ({self._source_id}): {e}. Reconnecting in {self._RECONNECT_DELAY}s..." + ) + await asyncio.sleep(self._RECONNECT_DELAY) + + def _handle_state_dump(self, states: list) -> None: + """Process initial state dump from get_states.""" + now = time.monotonic() + with self._lock: + for s in states: + eid = s.get("entity_id", "") + if not self._matches_filter(eid): + continue + self._states[eid] = HAEntityState( + entity_id=eid, + state=str(s.get("state", "")), + attributes=s.get("attributes", {}), + last_changed=s.get("last_changed", ""), + fetched_at=now, + ) + logger.info(f"HA {self._source_id}: loaded {len(self._states)} entity states") + + def _handle_state_changed(self, data: dict) -> None: + """Process a state_changed event.""" + new_state = data.get("new_state") + if new_state is None: + return + + eid = new_state.get("entity_id", "") + if not self._matches_filter(eid): + return + + now = time.monotonic() + entity_state = HAEntityState( + entity_id=eid, + state=str(new_state.get("state", "")), + attributes=new_state.get("attributes", {}), + last_changed=new_state.get("last_changed", ""), + fetched_at=now, + ) + + with self._lock: + self._states[eid] = entity_state + + # Dispatch callbacks + callbacks = self._callbacks.get(eid, set()) + for cb in callbacks: + try: + cb(entity_state) + except Exception as e: + logger.error(f"HA callback error ({eid}): {e}") + + async def fetch_entities(self) -> List[Dict[str, Any]]: + """Fetch entity list via one-shot WebSocket call (for API /entities endpoint).""" + try: + import websockets + except ImportError: + return [] + + try: + async with websockets.connect(self._ws_url) as ws: + # Auth + msg = json.loads(await ws.recv()) + if msg.get("type") != "auth_required": + return [] + await ws.send( + json.dumps( + { + "type": "auth", + "access_token": self._token, + } + ) + ) + msg = json.loads(await ws.recv()) + if msg.get("type") != "auth_ok": + return [] + + # Get states + req_id = 1 + await ws.send(json.dumps({"id": req_id, "type": "get_states"})) + msg = json.loads(await ws.recv()) + if msg.get("type") == "result" and msg.get("success"): + entities = [] + for s in msg.get("result", []): + eid = s.get("entity_id", "") + if self._matches_filter(eid): + entities.append( + { + "entity_id": eid, + "state": s.get("state", ""), + "friendly_name": s.get("attributes", {}).get( + "friendly_name", eid + ), + "domain": eid.split(".")[0] if "." in eid else "", + } + ) + return entities + except Exception as e: + logger.warning(f"HA fetch_entities failed ({self._source_id}): {e}") + return [] diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index b02eba0..bd3924d 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -42,6 +42,8 @@ from wled_controller.storage.weather_source_store import WeatherSourceStore from wled_controller.storage.asset_store import AssetStore from wled_controller.core.processing.sync_clock_manager import SyncClockManager from wled_controller.core.weather.weather_manager import WeatherManager +from wled_controller.storage.home_assistant_store import HomeAssistantStore +from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.core.mqtt.mqtt_service import MQTTService from wled_controller.core.devices.mqtt_client import set_mqtt_service @@ -95,6 +97,8 @@ asset_store.import_prebuilt_sounds(_prebuilt_sounds_dir) sync_clock_manager = SyncClockManager(sync_clock_store) weather_manager = WeatherManager(weather_source_store) +ha_store = HomeAssistantStore(db) +ha_manager = HomeAssistantManager(ha_store) processor_manager = ProcessorManager( ProcessorDependencies( @@ -160,6 +164,7 @@ async def lifespan(app: FastAPI): scene_preset_store=scene_preset_store, target_store=output_target_store, device_store=device_store, + ha_manager=ha_manager, ) # Create auto-backup engine — derive paths from database location so that @@ -208,6 +213,8 @@ async def lifespan(app: FastAPI): weather_manager=weather_manager, update_service=update_service, asset_store=asset_store, + ha_store=ha_store, + ha_manager=ha_manager, ) # Register devices in processor manager for health monitoring @@ -274,6 +281,12 @@ async def lifespan(app: FastAPI): # where no CRUD happened during the session. _save_all_stores() + # Stop Home Assistant manager + try: + await ha_manager.shutdown() + except Exception as e: + logger.error(f"Error stopping Home Assistant manager: {e}") + # Stop weather manager try: weather_manager.shutdown() diff --git a/server/src/wled_controller/static/js/core/icon-paths.ts b/server/src/wled_controller/static/js/core/icon-paths.ts index 1252d30..90131bf 100644 --- a/server/src/wled_controller/static/js/core/icon-paths.ts +++ b/server/src/wled_controller/static/js/core/icon-paths.ts @@ -89,3 +89,5 @@ export const fileAudio = ''; export const heart = ''; export const github = ''; +export const home = ''; +export const lock = ''; diff --git a/server/src/wled_controller/static/js/core/state.ts b/server/src/wled_controller/static/js/core/state.ts index 365b586..f2699f9 100644 --- a/server/src/wled_controller/static/js/core/state.ts +++ b/server/src/wled_controller/static/js/core/state.ts @@ -10,7 +10,7 @@ import { DataCache } from './cache.ts'; import type { Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource, AudioSource, PictureSource, ScenePreset, - SyncClock, WeatherSource, Asset, Automation, Display, FilterDef, EngineInfo, + SyncClock, WeatherSource, HomeAssistantSource, Asset, Automation, Display, FilterDef, EngineInfo, CaptureTemplate, PostprocessingTemplate, AudioTemplate, ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle, } from '../types.ts'; @@ -226,6 +226,7 @@ export let _cachedValueSources: ValueSource[] = []; // Sync clocks export let _cachedSyncClocks: SyncClock[] = []; export let _cachedWeatherSources: WeatherSource[] = []; +export let _cachedHASources: HomeAssistantSource[] = []; export let _cachedAssets: Asset[] = []; // Automations @@ -290,6 +291,12 @@ export const weatherSourcesCache = new DataCache({ }); weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; }); +export const haSourcesCache = new DataCache({ + endpoint: '/home-assistant/sources', + extractData: json => json.sources || [], +}); +haSourcesCache.subscribe(v => { _cachedHASources = v; }); + export const assetsCache = new DataCache({ endpoint: '/assets', extractData: json => json.assets || [], diff --git a/server/src/wled_controller/static/js/features/automations.ts b/server/src/wled_controller/static/js/features/automations.ts index bf81cf3..780fa88 100644 --- a/server/src/wled_controller/static/js/features/automations.ts +++ b/server/src/wled_controller/static/js/features/automations.ts @@ -2,7 +2,7 @@ * Automations — automation cards, editor, condition builder, process picker, scene selector. */ -import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.ts'; +import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts'; @@ -248,6 +248,7 @@ const CONDITION_PILL_RENDERERS: Record = { }, mqtt: (c) => `${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}`, webhook: (c) => `${ICON_WEB} ${t('automations.condition.webhook')}`, + home_assistant: (c) => `${_icon(P.home)} ${t('automations.condition.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}`, }; function createAutomationCard(automation: Automation, sceneMap = new Map()) { @@ -515,11 +516,11 @@ export function addAutomationCondition() { _autoGenerateAutomationName(); } -const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook']; +const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant']; const CONDITION_TYPE_ICONS = { always: P.refreshCw, startup: P.power, application: P.smartphone, time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor, - mqtt: P.radio, webhook: P.globe, + mqtt: P.radio, webhook: P.globe, home_assistant: P.home, }; const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen']; @@ -726,6 +727,44 @@ function addAutomationConditionRow(condition: any) { `; return; } + if (type === 'home_assistant') { + const haSourceId = data.ha_source_id || ''; + const entityId = data.entity_id || ''; + const haState = data.state || ''; + const matchMode = data.match_mode || 'exact'; + // Build HA source options from cached data + const haOptions = _cachedHASources.map((s: any) => + `${escapeHtml(s.name)}` + ).join(''); + container.innerHTML = ` + + ${t('automations.condition.home_assistant.hint')} + + ${t('automations.condition.home_assistant.ha_source')} + + — + ${haOptions} + + + + ${t('automations.condition.home_assistant.entity_id')} + + + + ${t('automations.condition.home_assistant.state')} + + + + ${t('automations.condition.home_assistant.match_mode')} + + ${t('automations.condition.mqtt.match_mode.exact')} + ${t('automations.condition.mqtt.match_mode.contains')} + ${t('automations.condition.mqtt.match_mode.regex')} + + + `; + return; + } if (type === 'webhook') { if (data.token) { const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token; @@ -835,6 +874,14 @@ function getAutomationEditorConditions() { const cond: any = { condition_type: 'webhook' }; if (tokenInput && tokenInput.value) cond.token = tokenInput.value; conditions.push(cond); + } else if (condType === 'home_assistant') { + conditions.push({ + condition_type: 'home_assistant', + ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value, + entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLInputElement).value.trim(), + state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value, + match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact', + }); } else { const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value; const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim(); diff --git a/server/src/wled_controller/static/js/features/home-assistant-sources.ts b/server/src/wled_controller/static/js/features/home-assistant-sources.ts new file mode 100644 index 0000000..563292c --- /dev/null +++ b/server/src/wled_controller/static/js/features/home-assistant-sources.ts @@ -0,0 +1,309 @@ +/** + * Home Assistant Sources — CRUD, test, cards. + */ + +import { _cachedHASources, haSourcesCache } from '../core/state.ts'; +import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { t } from '../core/i18n.ts'; +import { Modal } from '../core/modal.ts'; +import { showToast, showConfirm } from '../core/ui.ts'; +import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts'; +import * as P from '../core/icon-paths.ts'; +import { wrapCard } from '../core/card-colors.ts'; +import { TagInput, renderTagChips } from '../core/tag-input.ts'; +import { loadPictureSources } from './streams.ts'; +import type { HomeAssistantSource } from '../types.ts'; + +const ICON_HA = `${P.home}`; + +// ── Modal ── + +let _haTagsInput: TagInput | null = null; + +class HASourceModal extends Modal { + constructor() { super('ha-source-modal'); } + + onForceClose() { + if (_haTagsInput) { _haTagsInput.destroy(); _haTagsInput = null; } + } + + snapshotValues() { + return { + name: (document.getElementById('ha-source-name') as HTMLInputElement).value, + host: (document.getElementById('ha-source-host') as HTMLInputElement).value, + token: (document.getElementById('ha-source-token') as HTMLInputElement).value, + use_ssl: (document.getElementById('ha-source-ssl') as HTMLInputElement).checked, + entity_filters: (document.getElementById('ha-source-filters') as HTMLInputElement).value, + description: (document.getElementById('ha-source-description') as HTMLInputElement).value, + tags: JSON.stringify(_haTagsInput ? _haTagsInput.getValue() : []), + }; + } +} + +const haSourceModal = new HASourceModal(); + +// ── Show / Close ── + +export async function showHASourceModal(editData: HomeAssistantSource | null = null): Promise { + const isEdit = !!editData; + const titleKey = isEdit ? 'ha_source.edit' : 'ha_source.add'; + document.getElementById('ha-source-modal-title')!.innerHTML = `${ICON_HA} ${t(titleKey)}`; + (document.getElementById('ha-source-id') as HTMLInputElement).value = editData?.id || ''; + (document.getElementById('ha-source-error') as HTMLElement).style.display = 'none'; + + if (isEdit) { + (document.getElementById('ha-source-name') as HTMLInputElement).value = editData.name || ''; + (document.getElementById('ha-source-host') as HTMLInputElement).value = editData.host || ''; + (document.getElementById('ha-source-token') as HTMLInputElement).value = ''; // never expose token + (document.getElementById('ha-source-ssl') as HTMLInputElement).checked = editData.use_ssl ?? false; + (document.getElementById('ha-source-filters') as HTMLInputElement).value = (editData.entity_filters || []).join(', '); + (document.getElementById('ha-source-description') as HTMLInputElement).value = editData.description || ''; + } else { + (document.getElementById('ha-source-name') as HTMLInputElement).value = ''; + (document.getElementById('ha-source-host') as HTMLInputElement).value = ''; + (document.getElementById('ha-source-token') as HTMLInputElement).value = ''; + (document.getElementById('ha-source-ssl') as HTMLInputElement).checked = false; + (document.getElementById('ha-source-filters') as HTMLInputElement).value = ''; + (document.getElementById('ha-source-description') as HTMLInputElement).value = ''; + } + + // Tags + if (_haTagsInput) { _haTagsInput.destroy(); _haTagsInput = null; } + _haTagsInput = new TagInput(document.getElementById('ha-source-tags-container'), { placeholder: t('tags.placeholder') }); + _haTagsInput.setValue(isEdit ? (editData.tags || []) : []); + + // Show/hide test button based on edit mode + const testBtn = document.getElementById('ha-source-test-btn'); + if (testBtn) testBtn.style.display = isEdit ? '' : 'none'; + + // Token hint + const tokenHint = document.getElementById('ha-source-token-hint'); + if (tokenHint) tokenHint.style.display = isEdit ? '' : 'none'; + + haSourceModal.open(); + haSourceModal.snapshot(); +} + +export async function closeHASourceModal(): Promise { + await haSourceModal.close(); +} + +// ── Save ── + +export async function saveHASource(): Promise { + const id = (document.getElementById('ha-source-id') as HTMLInputElement).value; + const name = (document.getElementById('ha-source-name') as HTMLInputElement).value.trim(); + const host = (document.getElementById('ha-source-host') as HTMLInputElement).value.trim(); + const token = (document.getElementById('ha-source-token') as HTMLInputElement).value.trim(); + const use_ssl = (document.getElementById('ha-source-ssl') as HTMLInputElement).checked; + const filtersRaw = (document.getElementById('ha-source-filters') as HTMLInputElement).value.trim(); + const entity_filters = filtersRaw ? filtersRaw.split(',').map(s => s.trim()).filter(Boolean) : []; + const description = (document.getElementById('ha-source-description') as HTMLInputElement).value.trim() || null; + + if (!name) { + haSourceModal.showError(t('ha_source.error.name_required')); + return; + } + if (!id && !host) { + haSourceModal.showError(t('ha_source.error.host_required')); + return; + } + if (!id && !token) { + haSourceModal.showError(t('ha_source.error.token_required')); + return; + } + + const payload: Record = { + name, use_ssl, entity_filters, description, + tags: _haTagsInput ? _haTagsInput.getValue() : [], + }; + // Only send host/token if provided (edit mode may leave token blank) + if (host) payload.host = host; + if (token) payload.token = token; + + try { + const method = id ? 'PUT' : 'POST'; + const url = id ? `/home-assistant/sources/${id}` : '/home-assistant/sources'; + const resp = await fetchWithAuth(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success'); + haSourceModal.forceClose(); + haSourcesCache.invalidate(); + await loadPictureSources(); + } catch (e: any) { + if (e.isAuth) return; + haSourceModal.showError(e.message); + } +} + +// ── Edit / Clone / Delete ── + +export async function editHASource(sourceId: string): Promise { + try { + const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`); + if (!resp.ok) throw new Error(t('ha_source.error.load')); + const data = await resp.json(); + await showHASourceModal(data); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function cloneHASource(sourceId: string): Promise { + try { + const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`); + if (!resp.ok) throw new Error(t('ha_source.error.load')); + const data = await resp.json(); + delete data.id; + data.name = data.name + ' (copy)'; + await showHASourceModal(data); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function deleteHASource(sourceId: string): Promise { + const confirmed = await showConfirm(t('ha_source.delete.confirm')); + if (!confirmed) return; + try { + const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`, { method: 'DELETE' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + showToast(t('ha_source.deleted'), 'success'); + haSourcesCache.invalidate(); + await loadPictureSources(); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +// ── Test ── + +export async function testHASource(): Promise { + const id = (document.getElementById('ha-source-id') as HTMLInputElement).value; + if (!id) return; + + const testBtn = document.getElementById('ha-source-test-btn'); + if (testBtn) testBtn.classList.add('loading'); + + try { + const resp = await fetchWithAuth(`/home-assistant/sources/${id}/test`, { method: 'POST' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + if (data.success) { + showToast(`${t('ha_source.test.success')} | HA ${data.ha_version} | ${data.entity_count} entities`, 'success'); + } else { + showToast(`${t('ha_source.test.failed')}: ${data.error}`, 'error'); + } + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } finally { + if (testBtn) testBtn.classList.remove('loading'); + } +} + +// ── Card rendering ── + +export function createHASourceCard(source: HomeAssistantSource) { + const statusDot = source.connected + ? `` + : ``; + + return wrapCard({ + type: 'template-card', + dataAttr: 'data-id', + id: source.id, + removeOnclick: `deleteHASource('${source.id}')`, + removeTitle: t('common.delete'), + content: ` + + ${ICON_HA} ${statusDot} ${escapeHtml(source.name)} + + + + ${P.wifi} ${escapeHtml(source.host)} + + ${source.connected ? ` + ${P.listChecks} ${source.entity_count} entities + ` : ''} + ${source.use_ssl ? ` + ${P.lock} SSL + ` : ''} + + ${renderTagChips(source.tags)} + ${source.description ? `${escapeHtml(source.description)}` : ''}`, + actions: ` + ${ICON_TEST} + ${ICON_CLONE} + ${ICON_EDIT}`, + }); +} + +// ── Event delegation ── + +const _haSourceActions: Record void> = { + test: (id) => _testHASourceFromCard(id), + clone: cloneHASource, + edit: editHASource, +}; + +async function _testHASourceFromCard(sourceId: string): Promise { + try { + const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}/test`, { method: 'POST' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + if (data.success) { + showToast(`HA ${data.ha_version} | ${data.entity_count} entities`, 'success'); + } else { + showToast(`${t('ha_source.test.failed')}: ${data.error}`, 'error'); + } + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export function initHASourceDelegation(container: HTMLElement): void { + container.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action]'); + if (!btn) return; + + const section = btn.closest('[data-card-section="ha-sources"]'); + if (!section) return; + const card = btn.closest('[data-id]'); + if (!card) return; + + const action = btn.dataset.action; + const id = card.getAttribute('data-id'); + if (!action || !id) return; + + const handler = _haSourceActions[action]; + if (handler) { + e.stopPropagation(); + handler(id); + } + }); +} + +// ── Expose to global scope for HTML template onclick handlers ── + +window.showHASourceModal = showHASourceModal; +window.closeHASourceModal = closeHASourceModal; +window.saveHASource = saveHASource; +window.editHASource = editHASource; +window.cloneHASource = cloneHASource; +window.deleteHASource = deleteHASource; +window.testHASource = testHASource; diff --git a/server/src/wled_controller/static/js/features/streams.ts b/server/src/wled_controller/static/js/features/streams.ts index 51c56d1..ad7c3ed 100644 --- a/server/src/wled_controller/static/js/features/streams.ts +++ b/server/src/wled_controller/static/js/features/streams.ts @@ -23,6 +23,7 @@ import { _cachedValueSources, _cachedSyncClocks, _cachedWeatherSources, + _cachedHASources, _cachedAudioTemplates, _cachedCSPTemplates, _csptModalFilters, set_csptModalFilters, @@ -34,7 +35,7 @@ import { _sourcesLoading, set_sourcesLoading, apiKey, streamsCache, ppTemplatesCache, captureTemplatesCache, - audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, assetsCache, _cachedAssets, filtersCache, + audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, haSourcesCache, assetsCache, _cachedAssets, filtersCache, colorStripSourcesCache, csptCache, stripFiltersCache, gradientsCache, GradientEntity, @@ -50,6 +51,7 @@ import { updateSubTabHash } from './tabs.ts'; import { createValueSourceCard } from './value-sources.ts'; import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts'; import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts'; +import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts'; import { createAssetCard, initAssetDelegation } from './assets.ts'; import { createColorStripCard } from './color-strips.ts'; import { initAudioSourceDelegation } from './audio-sources.ts'; @@ -98,6 +100,7 @@ const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }]; const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }]; const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }]; +const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }]; const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }]; const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }]; @@ -168,6 +171,7 @@ const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.secti const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction }); const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction }); const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction }); +const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction }); const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', gridClass: 'templates-grid', addCardOnclick: "showAssetUploadModal()", keyAttr: 'data-id', emptyKey: 'section.empty.assets', bulkActions: _assetDeleteAction }); const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction }); const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }]; @@ -282,6 +286,7 @@ export async function loadPictureSources() { valueSourcesCache.fetch(), syncClocksCache.fetch(), weatherSourcesCache.fetch(), + haSourcesCache.fetch(), assetsCache.fetch(), audioTemplatesCache.fetch(), colorStripSourcesCache.fetch(), @@ -340,6 +345,7 @@ const _streamSectionMap = { value: [csValueSources], sync: [csSyncClocks], weather: [csWeatherSources], + home_assistant: [csHASources], }; type StreamCardRenderer = (stream: any) => string; @@ -566,6 +572,7 @@ function renderPictureSourcesList(streams: any) { { key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length }, { key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length }, { key: 'weather', icon: `${P.cloudSun}`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length }, + { key: 'home_assistant', icon: `${P.home}`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length }, { key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length }, ]; @@ -620,6 +627,7 @@ function renderPictureSourcesList(streams: any) { { key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length }, { key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length }, { key: 'weather', titleKey: 'streams.group.weather', icon: `${P.cloudSun}`, count: _cachedWeatherSources.length }, + { key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `${P.home}`, count: _cachedHASources.length }, { key: 'assets', titleKey: 'streams.group.assets', icon: ICON_ASSET, count: _cachedAssets.length }, ] } @@ -781,6 +789,7 @@ function renderPictureSourcesList(streams: any) { const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) }))); const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) }))); const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) }))); + const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) }))); const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) }))); const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) }))); @@ -801,6 +810,7 @@ function renderPictureSourcesList(streams: any) { value: _cachedValueSources.length, sync: _cachedSyncClocks.length, weather: _cachedWeatherSources.length, + home_assistant: _cachedHASources.length, assets: _cachedAssets.length, }); csRawStreams.reconcile(rawStreamItems); @@ -819,6 +829,7 @@ function renderPictureSourcesList(streams: any) { csValueSources.reconcile(valueItems); csSyncClocks.reconcile(syncClockItems); csWeatherSources.reconcile(weatherSourceItems); + csHASources.reconcile(haSourceItems); csAssets.reconcile(assetItems); } else { // First render: build full HTML @@ -838,6 +849,7 @@ function renderPictureSourcesList(streams: any) { else if (tab.key === 'value') panelContent = csValueSources.render(valueItems); else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems); else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems); + else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems); else if (tab.key === 'assets') panelContent = csAssets.render(assetItems); else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems); else panelContent = csStaticStreams.render(staticItems); @@ -845,11 +857,12 @@ function renderPictureSourcesList(streams: any) { }).join(''); container.innerHTML = panels; - CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csAssets]); + CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csAssets]); // Event delegation for card actions (replaces inline onclick handlers) initSyncClockDelegation(container); initWeatherSourceDelegation(container); + initHASourceDelegation(container); initAudioSourceDelegation(container); initAssetDelegation(container); @@ -869,6 +882,7 @@ function renderPictureSourcesList(streams: any) { 'value-sources': 'value', 'sync-clocks': 'sync', 'weather-sources': 'weather', + 'ha-sources': 'home_assistant', 'assets': 'assets', }); } diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index 32f4b2c..45c42db 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -413,6 +413,27 @@ export interface WeatherSourceListResponse { count: number; } +// ── Home Assistant Source ──────────────────────────────────── + +export interface HomeAssistantSource { + id: string; + name: string; + host: string; + use_ssl: boolean; + entity_filters: string[]; + connected: boolean; + entity_count: number; + description?: string; + tags: string[]; + created_at: string; + updated_at: string; +} + +export interface HomeAssistantSourceListResponse { + sources: HomeAssistantSource[]; + count: number; +} + // ── Asset ──────────────────────────────────────────────────── export interface Asset { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 8254b31..e9e3405 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1784,6 +1784,43 @@ "weather_source.geo.error": "Geolocation failed", "weather_source.geo.not_supported": "Geolocation is not supported by your browser", "streams.group.weather": "Weather", + "streams.group.home_assistant": "Home Assistant", + "ha_source.group.title": "Home Assistant Sources", + "ha_source.add": "Add Home Assistant Source", + "ha_source.edit": "Edit Home Assistant Source", + "ha_source.name": "Name:", + "ha_source.name.placeholder": "My Home Assistant", + "ha_source.name.hint": "A descriptive name for this Home Assistant connection", + "ha_source.host": "Host:", + "ha_source.host.hint": "Home Assistant host and port, e.g. 192.168.1.100:8123", + "ha_source.token": "Access Token:", + "ha_source.token.hint": "Long-Lived Access Token from HA (Profile > Security > Long-Lived Access Tokens)", + "ha_source.token.edit_hint": "Leave blank to keep the current token", + "ha_source.use_ssl": "Use SSL (wss://)", + "ha_source.entity_filters": "Entity Filters (optional):", + "ha_source.entity_filters.hint": "Comma-separated glob patterns, e.g. sensor.*, binary_sensor.front_door. Leave empty for all.", + "ha_source.description": "Description (optional):", + "ha_source.test": "Test Connection", + "ha_source.test.success": "Connected", + "ha_source.test.failed": "Connection failed", + "ha_source.connected": "Connected", + "ha_source.disconnected": "Disconnected", + "ha_source.error.name_required": "Name is required", + "ha_source.error.host_required": "Host is required", + "ha_source.error.token_required": "Access token is required", + "ha_source.error.load": "Failed to load Home Assistant source", + "ha_source.created": "Home Assistant source created", + "ha_source.updated": "Home Assistant source updated", + "ha_source.deleted": "Home Assistant source deleted", + "ha_source.delete.confirm": "Delete this Home Assistant connection?", + "section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.", + "automations.condition.home_assistant": "Home Assistant", + "automations.condition.home_assistant.desc": "HA entity state", + "automations.condition.home_assistant.ha_source": "HA Source:", + "automations.condition.home_assistant.entity_id": "Entity ID:", + "automations.condition.home_assistant.state": "State:", + "automations.condition.home_assistant.match_mode": "Match Mode:", + "automations.condition.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state", "color_strip.clock": "Sync Clock:", "color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.", "graph.title": "Graph", diff --git a/server/src/wled_controller/storage/automation.py b/server/src/wled_controller/storage/automation.py index 477ea49..b813b8a 100644 --- a/server/src/wled_controller/storage/automation.py +++ b/server/src/wled_controller/storage/automation.py @@ -180,6 +180,34 @@ class StartupCondition(Condition): return cls() +@dataclass +class HomeAssistantCondition(Condition): + """Activate based on a Home Assistant entity state.""" + + condition_type: str = "home_assistant" + ha_source_id: str = "" # references HomeAssistantSource + entity_id: str = "" # e.g. "binary_sensor.front_door" + state: str = "" # expected state value + match_mode: str = "exact" # "exact" | "contains" | "regex" + + def to_dict(self) -> dict: + d = super().to_dict() + d["ha_source_id"] = self.ha_source_id + d["entity_id"] = self.entity_id + d["state"] = self.state + d["match_mode"] = self.match_mode + return d + + @classmethod + def from_dict(cls, data: dict) -> "HomeAssistantCondition": + return cls( + ha_source_id=data.get("ha_source_id", ""), + entity_id=data.get("entity_id", ""), + state=data.get("state", ""), + match_mode=data.get("match_mode", "exact"), + ) + + _CONDITION_MAP: Dict[str, Type[Condition]] = { "always": AlwaysCondition, "application": ApplicationCondition, @@ -189,6 +217,7 @@ _CONDITION_MAP: Dict[str, Type[Condition]] = { "mqtt": MQTTCondition, "webhook": WebhookCondition, "startup": StartupCondition, + "home_assistant": HomeAssistantCondition, } @@ -243,6 +272,10 @@ class Automation: deactivation_mode=data.get("deactivation_mode", "none"), deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"), tags=data.get("tags", []), - created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())), - updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())), + created_at=datetime.fromisoformat( + data.get("created_at", datetime.now(timezone.utc).isoformat()) + ), + updated_at=datetime.fromisoformat( + data.get("updated_at", datetime.now(timezone.utc).isoformat()) + ), ) diff --git a/server/src/wled_controller/storage/database.py b/server/src/wled_controller/storage/database.py index bab5025..3dfe0d4 100644 --- a/server/src/wled_controller/storage/database.py +++ b/server/src/wled_controller/storage/database.py @@ -56,6 +56,7 @@ _ENTITY_TABLES = [ "gradients", "weather_sources", "assets", + "home_assistant_sources", ] @@ -96,30 +97,36 @@ class Database: """Create tables if they don't exist.""" with self._lock: # Schema version tracking - self._conn.execute(""" + self._conn.execute( + """ CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL ) - """) + """ + ) # Key-value settings table - self._conn.execute(""" + self._conn.execute( + """ CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) - """) + """ + ) # Create entity tables for table in _ENTITY_TABLES: - self._conn.execute(f""" + self._conn.execute( + f""" CREATE TABLE IF NOT EXISTS [{table}] ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', data TEXT NOT NULL ) - """) + """ + ) self._conn.execute( f"CREATE INDEX IF NOT EXISTS idx_{table}_name ON [{table}](name)" ) @@ -131,6 +138,7 @@ class Database: ).fetchone() if not existing: from datetime import datetime, timezone + self._conn.execute( "INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (?, ?)", (_SCHEMA_VERSION, datetime.now(timezone.utc).isoformat()), @@ -181,9 +189,7 @@ class Database: """ _check_table(table) with self._lock: - rows = self._conn.execute( - f"SELECT id, data FROM [{table}]" - ).fetchall() + rows = self._conn.execute(f"SELECT id, data FROM [{table}]").fetchall() result = [] for row in rows: try: @@ -218,9 +224,7 @@ class Database: if _writes_frozen: return with self._lock: - self._conn.execute( - f"DELETE FROM [{table}] WHERE id = ?", (item_id,) - ) + self._conn.execute(f"DELETE FROM [{table}] WHERE id = ?", (item_id,)) self._conn.commit() def delete_all(self, table: str) -> None: @@ -254,9 +258,7 @@ class Database: """Count rows in an entity table.""" _check_table(table) with self._lock: - row = self._conn.execute( - f"SELECT COUNT(*) as cnt FROM [{table}]" - ).fetchone() + row = self._conn.execute(f"SELECT COUNT(*) as cnt FROM [{table}]").fetchone() return row["cnt"] def table_exists_with_data(self, table: str) -> bool: @@ -264,9 +266,7 @@ class Database: _check_table(table) with self._lock: try: - row = self._conn.execute( - f"SELECT COUNT(*) as cnt FROM [{table}]" - ).fetchone() + row = self._conn.execute(f"SELECT COUNT(*) as cnt FROM [{table}]").fetchone() return row["cnt"] > 0 except sqlite3.OperationalError as e: logger.debug("Table %s does not exist or is inaccessible: %s", table, e) @@ -277,9 +277,7 @@ class Database: def get_setting(self, key: str) -> dict | None: """Read a setting by key. Returns parsed JSON dict, or None if not found.""" with self._lock: - row = self._conn.execute( - "SELECT value FROM settings WHERE key = ?", (key,) - ).fetchone() + row = self._conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() if row is None: return None try: diff --git a/server/src/wled_controller/storage/home_assistant_source.py b/server/src/wled_controller/storage/home_assistant_source.py new file mode 100644 index 0000000..1504580 --- /dev/null +++ b/server/src/wled_controller/storage/home_assistant_source.py @@ -0,0 +1,87 @@ +"""Home Assistant source data model. + +A HomeAssistantSource represents a connection to a Home Assistant instance. +It stores the host, access token, and optional entity filters. +Referenced by HomeAssistantColorStripSource and HomeAssistantCondition. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import List, Optional + + +def _parse_common(data: dict) -> dict: + """Extract common fields from a dict, parsing timestamps.""" + created = data.get("created_at", "") + updated = data.get("updated_at", "") + return { + "id": data["id"], + "name": data["name"], + "created_at": ( + datetime.fromisoformat(created) + if isinstance(created, str) and created + else datetime.now(timezone.utc) + ), + "updated_at": ( + datetime.fromisoformat(updated) + if isinstance(updated, str) and updated + else datetime.now(timezone.utc) + ), + "description": data.get("description"), + "tags": data.get("tags") or [], + } + + +@dataclass +class HomeAssistantSource: + """Home Assistant connection configuration.""" + + id: str + name: str + created_at: datetime + updated_at: datetime + host: str = "" # e.g. "192.168.1.100:8123" + token: str = "" # Long-Lived Access Token + use_ssl: bool = False + entity_filters: List[str] = field( + default_factory=list + ) # optional allowlist (e.g. ["sensor.*"]) + description: Optional[str] = None + tags: List[str] = field(default_factory=list) + + @property + def ws_url(self) -> str: + """Build WebSocket URL from host and SSL setting.""" + scheme = "wss" if self.use_ssl else "ws" + return f"{scheme}://{self.host}/api/websocket" + + @property + def http_url(self) -> str: + """Build HTTP URL from host and SSL setting.""" + scheme = "https" if self.use_ssl else "http" + return f"{scheme}://{self.host}" + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "host": self.host, + "token": self.token, + "use_ssl": self.use_ssl, + "entity_filters": list(self.entity_filters), + "description": self.description, + "tags": self.tags, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + @staticmethod + def from_dict(data: dict) -> "HomeAssistantSource": + common = _parse_common(data) + return HomeAssistantSource( + **common, + host=data.get("host", ""), + token=data.get("token", ""), + use_ssl=data.get("use_ssl", False), + entity_filters=data.get("entity_filters") or [], + ) diff --git a/server/src/wled_controller/storage/home_assistant_store.py b/server/src/wled_controller/storage/home_assistant_store.py new file mode 100644 index 0000000..599a848 --- /dev/null +++ b/server/src/wled_controller/storage/home_assistant_store.py @@ -0,0 +1,101 @@ +"""Home Assistant source storage using SQLite.""" + +import uuid +from datetime import datetime, timezone +from typing import List, Optional + +from wled_controller.storage.base_sqlite_store import BaseSqliteStore +from wled_controller.storage.database import Database +from wled_controller.storage.home_assistant_source import HomeAssistantSource +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class HomeAssistantStore(BaseSqliteStore[HomeAssistantSource]): + """Persistent storage for Home Assistant sources.""" + + _table_name = "home_assistant_sources" + _entity_name = "Home Assistant source" + + def __init__(self, db: Database): + super().__init__(db, HomeAssistantSource.from_dict) + + # Backward-compatible aliases + get_all_sources = BaseSqliteStore.get_all + get_source = BaseSqliteStore.get + delete_source = BaseSqliteStore.delete + + def create_source( + self, + name: str, + host: str, + token: str, + use_ssl: bool = False, + entity_filters: Optional[List[str]] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> HomeAssistantSource: + if not host: + raise ValueError("host is required") + if not token: + raise ValueError("token is required") + + self._check_name_unique(name) + + sid = f"has_{uuid.uuid4().hex[:8]}" + now = datetime.now(timezone.utc) + + source = HomeAssistantSource( + id=sid, + name=name, + created_at=now, + updated_at=now, + host=host, + token=token, + use_ssl=use_ssl, + entity_filters=entity_filters or [], + description=description, + tags=tags or [], + ) + + self._items[sid] = source + self._save_item(sid, source) + logger.info(f"Created Home Assistant source: {name} ({sid})") + return source + + def update_source( + self, + source_id: str, + name: Optional[str] = None, + host: Optional[str] = None, + token: Optional[str] = None, + use_ssl: Optional[bool] = None, + entity_filters: Optional[List[str]] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> HomeAssistantSource: + existing = self.get(source_id) + + if name is not None and name != existing.name: + self._check_name_unique(name) + + updated = HomeAssistantSource( + id=existing.id, + name=name if name is not None else existing.name, + created_at=existing.created_at, + updated_at=datetime.now(timezone.utc), + host=host if host is not None else existing.host, + token=token if token is not None else existing.token, + use_ssl=use_ssl if use_ssl is not None else existing.use_ssl, + entity_filters=( + entity_filters if entity_filters is not None else existing.entity_filters + ), + description=description if description is not None else existing.description, + tags=tags if tags is not None else existing.tags, + ) + + self._items[source_id] = updated + self._save_item(source_id, updated) + logger.info(f"Updated Home Assistant source: {updated.name} ({source_id})") + return updated diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 8a55a7c..834aafa 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -213,6 +213,7 @@ {% include 'modals/test-value-source.html' %} {% include 'modals/sync-clock-editor.html' %} {% include 'modals/weather-source-editor.html' %} + {% include 'modals/ha-source-editor.html' %} {% include 'modals/asset-upload.html' %} {% include 'modals/asset-editor.html' %} {% include 'modals/settings.html' %} diff --git a/server/src/wled_controller/templates/modals/ha-source-editor.html b/server/src/wled_controller/templates/modals/ha-source-editor.html new file mode 100644 index 0000000..59af7b3 --- /dev/null +++ b/server/src/wled_controller/templates/modals/ha-source-editor.html @@ -0,0 +1,82 @@ + + + + + Add Home Assistant Source + ✕ + + + + + + + + + + + Name: + ? + + A descriptive name for this Home Assistant connection + + + + + + + + Host: + ? + + Home Assistant host and port, e.g. 192.168.1.100:8123 + + + + + + + Access Token: + ? + + Long-Lived Access Token from HA (Profile > Security > Long-Lived Access Tokens) + Leave blank to keep the current token + + + + + + + + Use SSL (wss://) + + + + + + + Entity Filters (optional): + ? + + Comma-separated glob patterns to filter entities, e.g. sensor.*, binary_sensor.front_door. Leave empty for all entities. + + + + + + + Description (optional): + + + + + + + + +