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

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

View File

@@ -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.

View File

@@ -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]

View File

@@ -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"]

View File

@@ -21,7 +21,9 @@ from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore,
)
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.storage.asset_store import AssetStore
@@ -30,6 +32,8 @@ from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.update.update_service import UpdateService
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
T = TypeVar("T")
@@ -136,6 +140,14 @@ def get_asset_store() -> AssetStore:
return _get("asset_store", "Asset store")
def get_ha_store() -> HomeAssistantStore:
return _get("ha_store", "Home Assistant store")
def get_ha_manager() -> HomeAssistantManager:
return _get("ha_manager", "Home Assistant manager")
def get_database() -> Database:
return _get("database", "Database")
@@ -157,12 +169,14 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""
pm = _deps.get("processor_manager")
if pm is not None:
pm.fire_event({
pm.fire_event(
{
"type": "entity_changed",
"entity_type": entity_type,
"action": action,
"id": entity_id,
})
}
)
# ── Initialization ──────────────────────────────────────────────────────
@@ -193,9 +207,12 @@ 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({
_deps.update(
{
"database": database,
"device_store": device_store,
"template_store": template_store,
@@ -220,4 +237,7 @@ def init_dependencies(
"weather_manager": weather_manager,
"update_service": update_service,
"asset_store": asset_store,
})
"ha_store": ha_store,
"ha_manager": ha_manager,
}
)

View File

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

View File

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

View File

@@ -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")

View File

@@ -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

View File

@@ -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
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))
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({
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)

View File

@@ -0,0 +1 @@
"""Home Assistant integration — WebSocket client, entity state cache, manager."""

View File

@@ -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")

View File

@@ -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 []

View File

@@ -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()

View File

@@ -89,3 +89,5 @@ export const fileAudio = '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4
export const packageIcon = '<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>';
export const heart = '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>';
export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>';
export const home = '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
export const lock = '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>';

View File

@@ -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<WeatherSource[]>({
});
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
export const haSourcesCache = new DataCache<HomeAssistantSource[]>({
endpoint: '/home-assistant/sources',
extractData: json => json.sources || [],
});
haSourcesCache.subscribe(v => { _cachedHASources = v; });
export const assetsCache = new DataCache<Asset[]>({
endpoint: '/assets',
extractData: json => json.assets || [],

View File

@@ -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<string, ConditionPillRenderer> = {
},
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.condition.webhook')}</span>`,
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.condition.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
};
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) {
</div>`;
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) =>
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.home_assistant.hint')}</small>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.ha_source')}</label>
<select class="condition-ha-source-id">
<option value="">—</option>
${haOptions}
</select>
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.entity_id')}</label>
<input type="text" class="condition-ha-entity-id" value="${escapeHtml(entityId)}" placeholder="binary_sensor.front_door">
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.state')}</label>
<input type="text" class="condition-ha-state" value="${escapeHtml(haState)}" placeholder="on">
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.match_mode')}</label>
<select class="condition-ha-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
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();

View File

@@ -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 = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
// ── 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<void> {
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<void> {
await haSourceModal.close();
}
// ── Save ──
export async function saveHASource(): Promise<void> {
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<string, any> = {
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<void> {
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<void> {
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<void> {
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<void> {
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
? `<span class="status-dot status-dot-active" title="${t('ha_source.connected')}"></span>`
: `<span class="status-dot status-dot-inactive" title="${t('ha_source.disconnected')}"></span>`;
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: source.id,
removeOnclick: `deleteHASource('${source.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_HA} ${statusDot} ${escapeHtml(source.name)}</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg> ${escapeHtml(source.host)}
</span>
${source.connected ? `<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg> ${source.entity_count} entities
</span>` : ''}
${source.use_ssl ? `<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg> SSL
</span>` : ''}
</div>
${renderTagChips(source.tags)}
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('ha_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Event delegation ──
const _haSourceActions: Record<string, (id: string) => void> = {
test: (id) => _testHASourceFromCard(id),
clone: cloneHASource,
edit: editHASource,
};
async function _testHASourceFromCard(sourceId: string): Promise<void> {
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<HTMLElement>('[data-action]');
if (!btn) return;
const section = btn.closest<HTMLElement>('[data-card-section="ha-sources"]');
if (!section) return;
const card = btn.closest<HTMLElement>('[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;

View File

@@ -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: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, 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: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, 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',
});
}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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())
),
)

View File

@@ -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:

View File

@@ -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 [],
)

View File

@@ -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

View File

@@ -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' %}

View File

@@ -0,0 +1,82 @@
<!-- Home Assistant Source Editor Modal -->
<div id="ha-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="ha-source-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="ha-source-modal-title" data-i18n="ha_source.add">Add Home Assistant Source</h2>
<button class="modal-close-btn" onclick="closeHASourceModal()" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="ha-source-form" onsubmit="return false;">
<input type="hidden" id="ha-source-id">
<div id="ha-source-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="ha-source-name" data-i18n="ha_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.name.hint">A descriptive name for this Home Assistant connection</small>
<input type="text" id="ha-source-name" data-i18n-placeholder="ha_source.name.placeholder" placeholder="My Home Assistant" required>
<div id="ha-source-tags-container"></div>
</div>
<!-- Host -->
<div class="form-group">
<div class="label-row">
<label for="ha-source-host" data-i18n="ha_source.host">Host:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.host.hint">Home Assistant host and port, e.g. 192.168.1.100:8123</small>
<input type="text" id="ha-source-host" placeholder="192.168.1.100:8123" required>
</div>
<!-- Token -->
<div class="form-group">
<div class="label-row">
<label for="ha-source-token" data-i18n="ha_source.token">Access Token:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.token.hint">Long-Lived Access Token from HA (Profile > Security > Long-Lived Access Tokens)</small>
<small id="ha-source-token-hint" class="input-hint" style="display:none" data-i18n="ha_source.token.edit_hint">Leave blank to keep the current token</small>
<input type="password" id="ha-source-token" placeholder="eyJ0eXAiOi...">
</div>
<!-- SSL -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="ha-source-ssl">
<span data-i18n="ha_source.use_ssl">Use SSL (wss://)</span>
</label>
</div>
<!-- Entity Filters -->
<div class="form-group">
<div class="label-row">
<label for="ha-source-filters" data-i18n="ha_source.entity_filters">Entity Filters (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.entity_filters.hint">Comma-separated glob patterns to filter entities, e.g. sensor.*, binary_sensor.front_door. Leave empty for all entities.</small>
<input type="text" id="ha-source-filters" placeholder="sensor.*, binary_sensor.*">
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="ha-source-description" data-i18n="ha_source.description">Description (optional):</label>
</div>
<input type="text" id="ha-source-description" placeholder="">
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeHASourceModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-secondary" id="ha-source-test-btn" onclick="testHASource()" title="Test" data-i18n-title="ha_source.test" style="display:none">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg>
</button>
<button class="btn btn-icon btn-primary" onclick="saveHASource()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>