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:
@@ -111,5 +111,6 @@ Needs deeper design discussion. Likely a new entity type `ColorStripSourceTransi
|
|||||||
|
|
||||||
## Remaining Open Discussion
|
## 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?
|
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.
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ dependencies = [
|
|||||||
"aiomqtt>=2.0.0",
|
"aiomqtt>=2.0.0",
|
||||||
"openrgb-python>=0.2.15",
|
"openrgb-python>=0.2.15",
|
||||||
"opencv-python-headless>=4.8.0",
|
"opencv-python-headless>=4.8.0",
|
||||||
|
"websockets>=13.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from .routes.gradients import router as gradients_router
|
|||||||
from .routes.weather_sources import router as weather_sources_router
|
from .routes.weather_sources import router as weather_sources_router
|
||||||
from .routes.update import router as update_router
|
from .routes.update import router as update_router
|
||||||
from .routes.assets import router as assets_router
|
from .routes.assets import router as assets_router
|
||||||
|
from .routes.home_assistant import router as home_assistant_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -54,5 +55,6 @@ router.include_router(gradients_router)
|
|||||||
router.include_router(weather_sources_router)
|
router.include_router(weather_sources_router)
|
||||||
router.include_router(update_router)
|
router.include_router(update_router)
|
||||||
router.include_router(assets_router)
|
router.include_router(assets_router)
|
||||||
|
router.include_router(home_assistant_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ from wled_controller.storage.value_source_store import ValueSourceStore
|
|||||||
from wled_controller.storage.automation_store import AutomationStore
|
from wled_controller.storage.automation_store import AutomationStore
|
||||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
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.gradient_store import GradientStore
|
||||||
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
||||||
from wled_controller.storage.asset_store import AssetStore
|
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.backup.auto_backup import AutoBackupEngine
|
||||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||||
from wled_controller.core.update.update_service import UpdateService
|
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")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -136,6 +140,14 @@ def get_asset_store() -> AssetStore:
|
|||||||
return _get("asset_store", "Asset store")
|
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:
|
def get_database() -> Database:
|
||||||
return _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")
|
pm = _deps.get("processor_manager")
|
||||||
if pm is not None:
|
if pm is not None:
|
||||||
pm.fire_event({
|
pm.fire_event(
|
||||||
"type": "entity_changed",
|
{
|
||||||
"entity_type": entity_type,
|
"type": "entity_changed",
|
||||||
"action": action,
|
"entity_type": entity_type,
|
||||||
"id": entity_id,
|
"action": action,
|
||||||
})
|
"id": entity_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Initialization ──────────────────────────────────────────────────────
|
# ── Initialization ──────────────────────────────────────────────────────
|
||||||
@@ -193,31 +207,37 @@ def init_dependencies(
|
|||||||
weather_manager: WeatherManager | None = None,
|
weather_manager: WeatherManager | None = None,
|
||||||
update_service: UpdateService | None = None,
|
update_service: UpdateService | None = None,
|
||||||
asset_store: AssetStore | None = None,
|
asset_store: AssetStore | None = None,
|
||||||
|
ha_store: HomeAssistantStore | None = None,
|
||||||
|
ha_manager: HomeAssistantManager | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
_deps.update({
|
_deps.update(
|
||||||
"database": database,
|
{
|
||||||
"device_store": device_store,
|
"database": database,
|
||||||
"template_store": template_store,
|
"device_store": device_store,
|
||||||
"processor_manager": processor_manager,
|
"template_store": template_store,
|
||||||
"pp_template_store": pp_template_store,
|
"processor_manager": processor_manager,
|
||||||
"pattern_template_store": pattern_template_store,
|
"pp_template_store": pp_template_store,
|
||||||
"picture_source_store": picture_source_store,
|
"pattern_template_store": pattern_template_store,
|
||||||
"output_target_store": output_target_store,
|
"picture_source_store": picture_source_store,
|
||||||
"color_strip_store": color_strip_store,
|
"output_target_store": output_target_store,
|
||||||
"audio_source_store": audio_source_store,
|
"color_strip_store": color_strip_store,
|
||||||
"audio_template_store": audio_template_store,
|
"audio_source_store": audio_source_store,
|
||||||
"value_source_store": value_source_store,
|
"audio_template_store": audio_template_store,
|
||||||
"automation_store": automation_store,
|
"value_source_store": value_source_store,
|
||||||
"scene_preset_store": scene_preset_store,
|
"automation_store": automation_store,
|
||||||
"automation_engine": automation_engine,
|
"scene_preset_store": scene_preset_store,
|
||||||
"auto_backup_engine": auto_backup_engine,
|
"automation_engine": automation_engine,
|
||||||
"sync_clock_store": sync_clock_store,
|
"auto_backup_engine": auto_backup_engine,
|
||||||
"sync_clock_manager": sync_clock_manager,
|
"sync_clock_store": sync_clock_store,
|
||||||
"cspt_store": cspt_store,
|
"sync_clock_manager": sync_clock_manager,
|
||||||
"gradient_store": gradient_store,
|
"cspt_store": cspt_store,
|
||||||
"weather_source_store": weather_source_store,
|
"gradient_store": gradient_store,
|
||||||
"weather_manager": weather_manager,
|
"weather_source_store": weather_source_store,
|
||||||
"update_service": update_service,
|
"weather_manager": weather_manager,
|
||||||
"asset_store": asset_store,
|
"update_service": update_service,
|
||||||
})
|
"asset_store": asset_store,
|
||||||
|
"ha_store": ha_store,
|
||||||
|
"ha_manager": ha_manager,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
306
server/src/wled_controller/api/routes/home_assistant.py
Normal file
306
server/src/wled_controller/api/routes/home_assistant.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
"""Home Assistant source routes: CRUD + test + entity list + status."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
fire_entity_event,
|
||||||
|
get_ha_manager,
|
||||||
|
get_ha_store,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.home_assistant import (
|
||||||
|
HomeAssistantConnectionStatus,
|
||||||
|
HomeAssistantEntityListResponse,
|
||||||
|
HomeAssistantEntityResponse,
|
||||||
|
HomeAssistantSourceCreate,
|
||||||
|
HomeAssistantSourceListResponse,
|
||||||
|
HomeAssistantSourceResponse,
|
||||||
|
HomeAssistantSourceUpdate,
|
||||||
|
HomeAssistantStatusResponse,
|
||||||
|
HomeAssistantTestResponse,
|
||||||
|
)
|
||||||
|
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
|
from wled_controller.core.home_assistant.ha_runtime import HARuntime
|
||||||
|
from wled_controller.storage.base_store import EntityNotFoundError
|
||||||
|
from wled_controller.storage.home_assistant_source import HomeAssistantSource
|
||||||
|
from wled_controller.storage.home_assistant_store import HomeAssistantStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(
|
||||||
|
source: HomeAssistantSource, manager: HomeAssistantManager
|
||||||
|
) -> HomeAssistantSourceResponse:
|
||||||
|
runtime = manager.get_runtime(source.id)
|
||||||
|
return HomeAssistantSourceResponse(
|
||||||
|
id=source.id,
|
||||||
|
name=source.name,
|
||||||
|
host=source.host,
|
||||||
|
use_ssl=source.use_ssl,
|
||||||
|
entity_filters=source.entity_filters,
|
||||||
|
connected=runtime.is_connected if runtime else False,
|
||||||
|
entity_count=len(runtime.get_all_states()) if runtime else 0,
|
||||||
|
description=source.description,
|
||||||
|
tags=source.tags,
|
||||||
|
created_at=source.created_at,
|
||||||
|
updated_at=source.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/home-assistant/sources",
|
||||||
|
response_model=HomeAssistantSourceListResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def list_ha_sources(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
sources = store.get_all_sources()
|
||||||
|
return HomeAssistantSourceListResponse(
|
||||||
|
sources=[_to_response(s, manager) for s in sources],
|
||||||
|
count=len(sources),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/home-assistant/sources",
|
||||||
|
response_model=HomeAssistantSourceResponse,
|
||||||
|
status_code=201,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def create_ha_source(
|
||||||
|
data: HomeAssistantSourceCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
source = store.create_source(
|
||||||
|
name=data.name,
|
||||||
|
host=data.host,
|
||||||
|
token=data.token,
|
||||||
|
use_ssl=data.use_ssl,
|
||||||
|
entity_filters=data.entity_filters,
|
||||||
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
fire_entity_event("home_assistant_source", "created", source.id)
|
||||||
|
return _to_response(source, manager)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/home-assistant/sources/{source_id}",
|
||||||
|
response_model=HomeAssistantSourceResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def get_ha_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
source = store.get_source(source_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||||
|
return _to_response(source, manager)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/home-assistant/sources/{source_id}",
|
||||||
|
response_model=HomeAssistantSourceResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def update_ha_source(
|
||||||
|
source_id: str,
|
||||||
|
data: HomeAssistantSourceUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
source = store.update_source(
|
||||||
|
source_id,
|
||||||
|
name=data.name,
|
||||||
|
host=data.host,
|
||||||
|
token=data.token,
|
||||||
|
use_ssl=data.use_ssl,
|
||||||
|
entity_filters=data.entity_filters,
|
||||||
|
description=data.description,
|
||||||
|
tags=data.tags,
|
||||||
|
)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
await manager.update_source(source_id)
|
||||||
|
fire_entity_event("home_assistant_source", "updated", source.id)
|
||||||
|
return _to_response(source, manager)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/home-assistant/sources/{source_id}", status_code=204, tags=["Home Assistant"]
|
||||||
|
)
|
||||||
|
async def delete_ha_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
store.delete_source(source_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||||
|
# Release any active runtime
|
||||||
|
await manager.release(source_id)
|
||||||
|
fire_entity_event("home_assistant_source", "deleted", source_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/home-assistant/sources/{source_id}/entities",
|
||||||
|
response_model=HomeAssistantEntityListResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def list_ha_entities(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
"""List available entities from a HA instance (live query)."""
|
||||||
|
try:
|
||||||
|
source = store.get_source(source_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||||
|
|
||||||
|
# Try cached states first from running runtime
|
||||||
|
runtime = manager.get_runtime(source_id)
|
||||||
|
if runtime and runtime.is_connected:
|
||||||
|
states = runtime.get_all_states()
|
||||||
|
entities = [
|
||||||
|
HomeAssistantEntityResponse(
|
||||||
|
entity_id=s.entity_id,
|
||||||
|
state=s.state,
|
||||||
|
friendly_name=s.attributes.get("friendly_name", s.entity_id),
|
||||||
|
domain=s.entity_id.split(".")[0] if "." in s.entity_id else "",
|
||||||
|
)
|
||||||
|
for s in states.values()
|
||||||
|
]
|
||||||
|
return HomeAssistantEntityListResponse(entities=entities, count=len(entities))
|
||||||
|
|
||||||
|
# No active runtime — do a one-shot fetch
|
||||||
|
temp_runtime = HARuntime(source)
|
||||||
|
try:
|
||||||
|
raw_entities = await temp_runtime.fetch_entities()
|
||||||
|
finally:
|
||||||
|
await temp_runtime.stop()
|
||||||
|
|
||||||
|
entities = [HomeAssistantEntityResponse(**e) for e in raw_entities]
|
||||||
|
return HomeAssistantEntityListResponse(entities=entities, count=len(entities))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/home-assistant/sources/{source_id}/test",
|
||||||
|
response_model=HomeAssistantTestResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def test_ha_source(
|
||||||
|
source_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
):
|
||||||
|
"""Test connection to a Home Assistant instance."""
|
||||||
|
try:
|
||||||
|
source = store.get_source(source_id)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
return HomeAssistantTestResponse(
|
||||||
|
success=False,
|
||||||
|
error="websockets package not installed",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.connect(source.ws_url) as ws:
|
||||||
|
# Wait for auth_required
|
||||||
|
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
|
||||||
|
if msg.get("type") != "auth_required":
|
||||||
|
return HomeAssistantTestResponse(
|
||||||
|
success=False, error=f"Unexpected message: {msg.get('type')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
await ws.send(json.dumps({"type": "auth", "access_token": source.token}))
|
||||||
|
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
|
||||||
|
if msg.get("type") != "auth_ok":
|
||||||
|
return HomeAssistantTestResponse(
|
||||||
|
success=False, error=msg.get("message", "Auth failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
ha_version = msg.get("ha_version")
|
||||||
|
|
||||||
|
# Get entity count
|
||||||
|
await ws.send(json.dumps({"id": 1, "type": "get_states"}))
|
||||||
|
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=10.0))
|
||||||
|
entity_count = len(msg.get("result", [])) if msg.get("success") else 0
|
||||||
|
|
||||||
|
return HomeAssistantTestResponse(
|
||||||
|
success=True,
|
||||||
|
ha_version=ha_version,
|
||||||
|
entity_count=entity_count,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return HomeAssistantTestResponse(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/home-assistant/status",
|
||||||
|
response_model=HomeAssistantStatusResponse,
|
||||||
|
tags=["Home Assistant"],
|
||||||
|
)
|
||||||
|
async def get_ha_status(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: HomeAssistantStore = Depends(get_ha_store),
|
||||||
|
manager: HomeAssistantManager = Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
"""Get overall HA integration status (for dashboard indicators)."""
|
||||||
|
all_sources = store.get_all_sources()
|
||||||
|
conn_statuses = manager.get_connection_status()
|
||||||
|
|
||||||
|
# Build a map for quick lookup
|
||||||
|
status_map = {s["source_id"]: s for s in conn_statuses}
|
||||||
|
|
||||||
|
connections = []
|
||||||
|
connected_count = 0
|
||||||
|
for source in all_sources:
|
||||||
|
status = status_map.get(source.id)
|
||||||
|
connected = status["connected"] if status else False
|
||||||
|
if connected:
|
||||||
|
connected_count += 1
|
||||||
|
connections.append(
|
||||||
|
HomeAssistantConnectionStatus(
|
||||||
|
source_id=source.id,
|
||||||
|
name=source.name,
|
||||||
|
connected=connected,
|
||||||
|
entity_count=status["entity_count"] if status else 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return HomeAssistantStatusResponse(
|
||||||
|
connections=connections,
|
||||||
|
total_sources=len(all_sources),
|
||||||
|
connected_count=connected_count,
|
||||||
|
)
|
||||||
@@ -21,6 +21,8 @@ from wled_controller.api.dependencies import (
|
|||||||
get_automation_store,
|
get_automation_store,
|
||||||
get_color_strip_store,
|
get_color_strip_store,
|
||||||
get_device_store,
|
get_device_store,
|
||||||
|
get_ha_manager,
|
||||||
|
get_ha_store,
|
||||||
get_output_target_store,
|
get_output_target_store,
|
||||||
get_pattern_template_store,
|
get_pattern_template_store,
|
||||||
get_picture_source_store,
|
get_picture_source_store,
|
||||||
@@ -311,3 +313,52 @@ def list_api_keys(_: AuthRequired):
|
|||||||
for label, key in config.auth.api_keys.items()
|
for label, key in config.auth.api_keys.items()
|
||||||
]
|
]
|
||||||
return {"keys": keys, "count": len(keys)}
|
return {"keys": keys, "count": len(keys)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/integrations-status", tags=["System"])
|
||||||
|
async def get_integrations_status(
|
||||||
|
_: AuthRequired,
|
||||||
|
ha_store=Depends(get_ha_store),
|
||||||
|
ha_manager=Depends(get_ha_manager),
|
||||||
|
):
|
||||||
|
"""Return connection status for external integrations (MQTT, Home Assistant).
|
||||||
|
|
||||||
|
Used by the dashboard to show connectivity indicators.
|
||||||
|
"""
|
||||||
|
from wled_controller.core.devices.mqtt_client import get_mqtt_service
|
||||||
|
|
||||||
|
# MQTT status
|
||||||
|
mqtt_service = get_mqtt_service()
|
||||||
|
mqtt_config = get_config().mqtt
|
||||||
|
mqtt_status = {
|
||||||
|
"enabled": mqtt_config.enabled,
|
||||||
|
"connected": mqtt_service.is_connected if mqtt_service else False,
|
||||||
|
"broker": (
|
||||||
|
f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Home Assistant status
|
||||||
|
ha_sources = ha_store.get_all_sources()
|
||||||
|
ha_connections = ha_manager.get_connection_status()
|
||||||
|
ha_status_map = {s["source_id"]: s for s in ha_connections}
|
||||||
|
ha_items = []
|
||||||
|
for source in ha_sources:
|
||||||
|
status = ha_status_map.get(source.id)
|
||||||
|
ha_items.append(
|
||||||
|
{
|
||||||
|
"source_id": source.id,
|
||||||
|
"name": source.name,
|
||||||
|
"connected": status["connected"] if status else False,
|
||||||
|
"entity_count": status["entity_count"] if status else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mqtt": mqtt_status,
|
||||||
|
"home_assistant": {
|
||||||
|
"sources": ha_items,
|
||||||
|
"total": len(ha_sources),
|
||||||
|
"connected": sum(1 for s in ha_items if s["connected"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,21 +12,41 @@ class ConditionSchema(BaseModel):
|
|||||||
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
||||||
# Application condition fields
|
# Application condition fields
|
||||||
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
|
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
|
# 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)")
|
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
|
||||||
# System idle condition fields
|
# System idle condition fields
|
||||||
idle_minutes: Optional[int] = Field(None, description="Idle timeout in minutes (for system_idle condition)")
|
idle_minutes: Optional[int] = Field(
|
||||||
when_idle: Optional[bool] = Field(None, description="True=active when idle (for system_idle condition)")
|
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
|
# Display state condition fields
|
||||||
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
|
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
|
||||||
# MQTT condition fields
|
# MQTT condition fields
|
||||||
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
|
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)")
|
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
|
# 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):
|
class AutomationCreate(BaseModel):
|
||||||
@@ -35,10 +55,16 @@ class AutomationCreate(BaseModel):
|
|||||||
name: str = Field(description="Automation name", min_length=1, max_length=100)
|
name: str = Field(description="Automation name", min_length=1, max_length=100)
|
||||||
enabled: bool = Field(default=True, description="Whether the automation is enabled")
|
enabled: bool = Field(default=True, description="Whether the automation is enabled")
|
||||||
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
|
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")
|
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_mode: str = Field(
|
||||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
|
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")
|
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)
|
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")
|
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")
|
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
|
||||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
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_mode: Optional[str] = Field(
|
||||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
|
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
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -67,10 +99,16 @@ class AutomationResponse(BaseModel):
|
|||||||
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
||||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
|
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
|
||||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
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")
|
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_activated_at: Optional[datetime] = Field(
|
||||||
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this automation was deactivated")
|
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")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|||||||
97
server/src/wled_controller/api/schemas/home_assistant.py
Normal file
97
server/src/wled_controller/api/schemas/home_assistant.py
Normal 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
|
||||||
@@ -12,6 +12,7 @@ from wled_controller.storage.automation import (
|
|||||||
Automation,
|
Automation,
|
||||||
Condition,
|
Condition,
|
||||||
DisplayStateCondition,
|
DisplayStateCondition,
|
||||||
|
HomeAssistantCondition,
|
||||||
MQTTCondition,
|
MQTTCondition,
|
||||||
StartupCondition,
|
StartupCondition,
|
||||||
SystemIdleCondition,
|
SystemIdleCondition,
|
||||||
@@ -37,6 +38,7 @@ class AutomationEngine:
|
|||||||
scene_preset_store=None,
|
scene_preset_store=None,
|
||||||
target_store=None,
|
target_store=None,
|
||||||
device_store=None,
|
device_store=None,
|
||||||
|
ha_manager=None,
|
||||||
):
|
):
|
||||||
self._store = automation_store
|
self._store = automation_store
|
||||||
self._manager = processor_manager
|
self._manager = processor_manager
|
||||||
@@ -46,6 +48,7 @@ class AutomationEngine:
|
|||||||
self._scene_preset_store = scene_preset_store
|
self._scene_preset_store = scene_preset_store
|
||||||
self._target_store = target_store
|
self._target_store = target_store
|
||||||
self._device_store = device_store
|
self._device_store = device_store
|
||||||
|
self._ha_manager = ha_manager
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._task: Optional[asyncio.Task] = None
|
||||||
self._eval_lock = asyncio.Lock()
|
self._eval_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -101,8 +104,12 @@ class AutomationEngine:
|
|||||||
await self._evaluate_all_locked()
|
await self._evaluate_all_locked()
|
||||||
|
|
||||||
def _detect_all_sync(
|
def _detect_all_sync(
|
||||||
self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool,
|
self,
|
||||||
needs_idle: bool, needs_display_state: bool,
|
needs_running: bool,
|
||||||
|
needs_topmost: bool,
|
||||||
|
needs_fullscreen: bool,
|
||||||
|
needs_idle: bool,
|
||||||
|
needs_display_state: bool,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Run all platform detection in a single thread call.
|
"""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()
|
topmost_proc, topmost_fullscreen = self._detector._get_topmost_process_sync()
|
||||||
else:
|
else:
|
||||||
topmost_proc, topmost_fullscreen = None, False
|
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
|
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
|
display_state = (
|
||||||
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, 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:
|
async def _evaluate_all_locked(self) -> None:
|
||||||
automations = self._store.get_all_automations()
|
automations = self._store.get_all_automations()
|
||||||
@@ -148,24 +166,37 @@ class AutomationEngine:
|
|||||||
|
|
||||||
# Single executor call for all platform detection
|
# Single executor call for all platform detection
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
(running_procs, topmost_proc, topmost_fullscreen,
|
(
|
||||||
fullscreen_procs, idle_seconds, display_state) = (
|
running_procs,
|
||||||
await loop.run_in_executor(
|
topmost_proc,
|
||||||
None, self._detect_all_sync,
|
topmost_fullscreen,
|
||||||
needs_running, needs_topmost, needs_fullscreen,
|
fullscreen_procs,
|
||||||
needs_idle, needs_display_state,
|
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()
|
active_automation_ids = set()
|
||||||
|
|
||||||
for automation in automations:
|
for automation in automations:
|
||||||
should_be_active = (
|
should_be_active = automation.enabled and (
|
||||||
automation.enabled
|
len(automation.conditions) == 0
|
||||||
and (len(automation.conditions) == 0
|
or self._evaluate_conditions(
|
||||||
or self._evaluate_conditions(
|
automation,
|
||||||
automation, running_procs, topmost_proc, topmost_fullscreen,
|
running_procs,
|
||||||
fullscreen_procs, idle_seconds, display_state))
|
topmost_proc,
|
||||||
|
topmost_fullscreen,
|
||||||
|
fullscreen_procs,
|
||||||
|
idle_seconds,
|
||||||
|
display_state,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
is_active = automation.id in self._active_automations
|
is_active = automation.id in self._active_automations
|
||||||
@@ -184,15 +215,24 @@ class AutomationEngine:
|
|||||||
await self._deactivate_automation(aid)
|
await self._deactivate_automation(aid)
|
||||||
|
|
||||||
def _evaluate_conditions(
|
def _evaluate_conditions(
|
||||||
self, automation: Automation, running_procs: Set[str],
|
self,
|
||||||
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
automation: Automation,
|
||||||
|
running_procs: Set[str],
|
||||||
|
topmost_proc: Optional[str],
|
||||||
|
topmost_fullscreen: bool,
|
||||||
fullscreen_procs: Set[str],
|
fullscreen_procs: Set[str],
|
||||||
idle_seconds: Optional[float], display_state: Optional[str],
|
idle_seconds: Optional[float],
|
||||||
|
display_state: Optional[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
results = [
|
results = [
|
||||||
self._evaluate_condition(
|
self._evaluate_condition(
|
||||||
c, running_procs, topmost_proc, topmost_fullscreen,
|
c,
|
||||||
fullscreen_procs, idle_seconds, display_state,
|
running_procs,
|
||||||
|
topmost_proc,
|
||||||
|
topmost_fullscreen,
|
||||||
|
fullscreen_procs,
|
||||||
|
idle_seconds,
|
||||||
|
display_state,
|
||||||
)
|
)
|
||||||
for c in automation.conditions
|
for c in automation.conditions
|
||||||
]
|
]
|
||||||
@@ -202,20 +242,27 @@ class AutomationEngine:
|
|||||||
return any(results) # "or" is default
|
return any(results) # "or" is default
|
||||||
|
|
||||||
def _evaluate_condition(
|
def _evaluate_condition(
|
||||||
self, condition: Condition, running_procs: Set[str],
|
self,
|
||||||
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
condition: Condition,
|
||||||
|
running_procs: Set[str],
|
||||||
|
topmost_proc: Optional[str],
|
||||||
|
topmost_fullscreen: bool,
|
||||||
fullscreen_procs: Set[str],
|
fullscreen_procs: Set[str],
|
||||||
idle_seconds: Optional[float], display_state: Optional[str],
|
idle_seconds: Optional[float],
|
||||||
|
display_state: Optional[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
dispatch = {
|
dispatch = {
|
||||||
AlwaysCondition: lambda c: True,
|
AlwaysCondition: lambda c: True,
|
||||||
StartupCondition: 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),
|
TimeOfDayCondition: lambda c: self._evaluate_time_of_day(c),
|
||||||
SystemIdleCondition: lambda c: self._evaluate_idle(c, idle_seconds),
|
SystemIdleCondition: lambda c: self._evaluate_idle(c, idle_seconds),
|
||||||
DisplayStateCondition: lambda c: self._evaluate_display_state(c, display_state),
|
DisplayStateCondition: lambda c: self._evaluate_display_state(c, display_state),
|
||||||
MQTTCondition: lambda c: self._evaluate_mqtt(c),
|
MQTTCondition: lambda c: self._evaluate_mqtt(c),
|
||||||
WebhookCondition: lambda c: self._webhook_states.get(c.token, False),
|
WebhookCondition: lambda c: self._webhook_states.get(c.token, False),
|
||||||
|
HomeAssistantCondition: lambda c: self._evaluate_home_assistant(c),
|
||||||
}
|
}
|
||||||
handler = dispatch.get(type(condition))
|
handler = dispatch.get(type(condition))
|
||||||
if handler is None:
|
if handler is None:
|
||||||
@@ -243,7 +290,9 @@ class AutomationEngine:
|
|||||||
return is_idle if condition.when_idle else not is_idle
|
return is_idle if condition.when_idle else not is_idle
|
||||||
|
|
||||||
@staticmethod
|
@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:
|
if display_state is None:
|
||||||
return False
|
return False
|
||||||
return display_state == condition.state
|
return display_state == condition.state
|
||||||
@@ -268,6 +317,27 @@ class AutomationEngine:
|
|||||||
logger.debug("MQTT condition regex error: %s", e)
|
logger.debug("MQTT condition regex error: %s", e)
|
||||||
return False
|
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(
|
def _evaluate_app_condition(
|
||||||
self,
|
self,
|
||||||
condition: ApplicationCondition,
|
condition: ApplicationCondition,
|
||||||
@@ -289,8 +359,7 @@ class AutomationEngine:
|
|||||||
and any(app == topmost_proc for app in apps_lower)
|
and any(app == topmost_proc for app in apps_lower)
|
||||||
),
|
),
|
||||||
"topmost": lambda: (
|
"topmost": lambda: (
|
||||||
topmost_proc is not None
|
topmost_proc is not None and any(app == topmost_proc for app in apps_lower)
|
||||||
and any(app == topmost_proc for app in apps_lower)
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
handler = match_handlers.get(condition.match_type)
|
handler = match_handlers.get(condition.match_type)
|
||||||
@@ -316,12 +385,15 @@ class AutomationEngine:
|
|||||||
try:
|
try:
|
||||||
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
|
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
|
||||||
except ValueError:
|
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
|
return
|
||||||
|
|
||||||
# For "revert" mode, capture current state before activating
|
# For "revert" mode, capture current state before activating
|
||||||
if automation.deactivation_mode == "revert":
|
if automation.deactivation_mode == "revert":
|
||||||
from wled_controller.core.scenes.scene_activator import capture_current_snapshot
|
from wled_controller.core.scenes.scene_activator import capture_current_snapshot
|
||||||
|
|
||||||
targets = capture_current_snapshot(self._target_store, self._manager)
|
targets = capture_current_snapshot(self._target_store, self._manager)
|
||||||
self._pre_activation_snapshots[automation.id] = ScenePreset(
|
self._pre_activation_snapshots[automation.id] = ScenePreset(
|
||||||
id=f"_revert_{automation.id}",
|
id=f"_revert_{automation.id}",
|
||||||
@@ -331,8 +403,11 @@ class AutomationEngine:
|
|||||||
|
|
||||||
# Apply the scene
|
# Apply the scene
|
||||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
|
||||||
status, errors = await 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
|
self._active_automations[automation.id] = True
|
||||||
@@ -374,8 +449,11 @@ class AutomationEngine:
|
|||||||
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||||
if snapshot and self._target_store:
|
if snapshot and self._target_store:
|
||||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
|
||||||
status, errors = await apply_scene_state(
|
status, errors = await apply_scene_state(
|
||||||
snapshot, self._target_store, self._manager,
|
snapshot,
|
||||||
|
self._target_store,
|
||||||
|
self._manager,
|
||||||
)
|
)
|
||||||
if errors:
|
if errors:
|
||||||
logger.warning(f"Automation {automation_id} revert errors: {errors}")
|
logger.warning(f"Automation {automation_id} revert errors: {errors}")
|
||||||
@@ -391,25 +469,34 @@ class AutomationEngine:
|
|||||||
try:
|
try:
|
||||||
fallback = self._scene_preset_store.get_preset(fallback_id)
|
fallback = self._scene_preset_store.get_preset(fallback_id)
|
||||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
|
||||||
status, errors = await apply_scene_state(
|
status, errors = await apply_scene_state(
|
||||||
fallback, self._target_store, self._manager,
|
fallback,
|
||||||
|
self._target_store,
|
||||||
|
self._manager,
|
||||||
)
|
)
|
||||||
if errors:
|
if errors:
|
||||||
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
|
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
|
||||||
else:
|
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:
|
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:
|
else:
|
||||||
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
|
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
|
||||||
|
|
||||||
def _fire_event(self, automation_id: str, action: str) -> None:
|
def _fire_event(self, automation_id: str, action: str) -> None:
|
||||||
try:
|
try:
|
||||||
self._manager.fire_event({
|
self._manager.fire_event(
|
||||||
"type": "automation_state_changed",
|
{
|
||||||
"automation_id": automation_id,
|
"type": "automation_state_changed",
|
||||||
"action": action,
|
"automation_id": automation_id,
|
||||||
})
|
"action": action,
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Automation action failed: %s", e, exc_info=True)
|
logger.error("Automation action failed: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Home Assistant integration — WebSocket client, entity state cache, manager."""
|
||||||
128
server/src/wled_controller/core/home_assistant/ha_manager.py
Normal file
128
server/src/wled_controller/core/home_assistant/ha_manager.py
Normal 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")
|
||||||
309
server/src/wled_controller/core/home_assistant/ha_runtime.py
Normal file
309
server/src/wled_controller/core/home_assistant/ha_runtime.py
Normal 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 []
|
||||||
@@ -42,6 +42,8 @@ from wled_controller.storage.weather_source_store import WeatherSourceStore
|
|||||||
from wled_controller.storage.asset_store import AssetStore
|
from wled_controller.storage.asset_store import AssetStore
|
||||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
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.automations.automation_engine import AutomationEngine
|
||||||
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
||||||
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
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)
|
sync_clock_manager = SyncClockManager(sync_clock_store)
|
||||||
weather_manager = WeatherManager(weather_source_store)
|
weather_manager = WeatherManager(weather_source_store)
|
||||||
|
ha_store = HomeAssistantStore(db)
|
||||||
|
ha_manager = HomeAssistantManager(ha_store)
|
||||||
|
|
||||||
processor_manager = ProcessorManager(
|
processor_manager = ProcessorManager(
|
||||||
ProcessorDependencies(
|
ProcessorDependencies(
|
||||||
@@ -160,6 +164,7 @@ async def lifespan(app: FastAPI):
|
|||||||
scene_preset_store=scene_preset_store,
|
scene_preset_store=scene_preset_store,
|
||||||
target_store=output_target_store,
|
target_store=output_target_store,
|
||||||
device_store=device_store,
|
device_store=device_store,
|
||||||
|
ha_manager=ha_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create auto-backup engine — derive paths from database location so that
|
# Create auto-backup engine — derive paths from database location so that
|
||||||
@@ -208,6 +213,8 @@ async def lifespan(app: FastAPI):
|
|||||||
weather_manager=weather_manager,
|
weather_manager=weather_manager,
|
||||||
update_service=update_service,
|
update_service=update_service,
|
||||||
asset_store=asset_store,
|
asset_store=asset_store,
|
||||||
|
ha_store=ha_store,
|
||||||
|
ha_manager=ha_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register devices in processor manager for health monitoring
|
# Register devices in processor manager for health monitoring
|
||||||
@@ -274,6 +281,12 @@ async def lifespan(app: FastAPI):
|
|||||||
# where no CRUD happened during the session.
|
# where no CRUD happened during the session.
|
||||||
_save_all_stores()
|
_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
|
# Stop weather manager
|
||||||
try:
|
try:
|
||||||
weather_manager.shutdown()
|
weather_manager.shutdown()
|
||||||
|
|||||||
@@ -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 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 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 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"/>';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DataCache } from './cache.ts';
|
|||||||
import type {
|
import type {
|
||||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||||
SyncClock, WeatherSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
SyncClock, WeatherSource, HomeAssistantSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||||
} from '../types.ts';
|
} from '../types.ts';
|
||||||
@@ -226,6 +226,7 @@ export let _cachedValueSources: ValueSource[] = [];
|
|||||||
// Sync clocks
|
// Sync clocks
|
||||||
export let _cachedSyncClocks: SyncClock[] = [];
|
export let _cachedSyncClocks: SyncClock[] = [];
|
||||||
export let _cachedWeatherSources: WeatherSource[] = [];
|
export let _cachedWeatherSources: WeatherSource[] = [];
|
||||||
|
export let _cachedHASources: HomeAssistantSource[] = [];
|
||||||
export let _cachedAssets: Asset[] = [];
|
export let _cachedAssets: Asset[] = [];
|
||||||
|
|
||||||
// Automations
|
// Automations
|
||||||
@@ -290,6 +291,12 @@ export const weatherSourcesCache = new DataCache<WeatherSource[]>({
|
|||||||
});
|
});
|
||||||
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
|
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[]>({
|
export const assetsCache = new DataCache<Asset[]>({
|
||||||
endpoint: '/assets',
|
endpoint: '/assets',
|
||||||
extractData: json => json.assets || [],
|
extractData: json => json.assets || [],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Automations — automation cards, editor, condition builder, process picker, scene selector.
|
* 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 { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.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>`,
|
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>`,
|
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()) {
|
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
||||||
@@ -515,11 +516,11 @@ export function addAutomationCondition() {
|
|||||||
_autoGenerateAutomationName();
|
_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 = {
|
const CONDITION_TYPE_ICONS = {
|
||||||
always: P.refreshCw, startup: P.power, application: P.smartphone,
|
always: P.refreshCw, startup: P.power, application: P.smartphone,
|
||||||
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
|
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'];
|
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
|
||||||
@@ -726,6 +727,44 @@ function addAutomationConditionRow(condition: any) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
return;
|
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 (type === 'webhook') {
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
|
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
|
||||||
@@ -835,6 +874,14 @@ function getAutomationEditorConditions() {
|
|||||||
const cond: any = { condition_type: 'webhook' };
|
const cond: any = { condition_type: 'webhook' };
|
||||||
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
|
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
|
||||||
conditions.push(cond);
|
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 {
|
} else {
|
||||||
const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
|
const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
|
||||||
const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
|
const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
_cachedValueSources,
|
_cachedValueSources,
|
||||||
_cachedSyncClocks,
|
_cachedSyncClocks,
|
||||||
_cachedWeatherSources,
|
_cachedWeatherSources,
|
||||||
|
_cachedHASources,
|
||||||
_cachedAudioTemplates,
|
_cachedAudioTemplates,
|
||||||
_cachedCSPTemplates,
|
_cachedCSPTemplates,
|
||||||
_csptModalFilters, set_csptModalFilters,
|
_csptModalFilters, set_csptModalFilters,
|
||||||
@@ -34,7 +35,7 @@ import {
|
|||||||
_sourcesLoading, set_sourcesLoading,
|
_sourcesLoading, set_sourcesLoading,
|
||||||
apiKey,
|
apiKey,
|
||||||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
||||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, assetsCache, _cachedAssets, filtersCache,
|
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, haSourcesCache, assetsCache, _cachedAssets, filtersCache,
|
||||||
colorStripSourcesCache,
|
colorStripSourcesCache,
|
||||||
csptCache, stripFiltersCache,
|
csptCache, stripFiltersCache,
|
||||||
gradientsCache, GradientEntity,
|
gradientsCache, GradientEntity,
|
||||||
@@ -50,6 +51,7 @@ import { updateSubTabHash } from './tabs.ts';
|
|||||||
import { createValueSourceCard } from './value-sources.ts';
|
import { createValueSourceCard } from './value-sources.ts';
|
||||||
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
||||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||||
|
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
||||||
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
||||||
import { createColorStripCard } from './color-strips.ts';
|
import { createColorStripCard } from './color-strips.ts';
|
||||||
import { initAudioSourceDelegation } from './audio-sources.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 _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 _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 _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 _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') }];
|
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 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 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 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 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 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') }];
|
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(),
|
valueSourcesCache.fetch(),
|
||||||
syncClocksCache.fetch(),
|
syncClocksCache.fetch(),
|
||||||
weatherSourcesCache.fetch(),
|
weatherSourcesCache.fetch(),
|
||||||
|
haSourcesCache.fetch(),
|
||||||
assetsCache.fetch(),
|
assetsCache.fetch(),
|
||||||
audioTemplatesCache.fetch(),
|
audioTemplatesCache.fetch(),
|
||||||
colorStripSourcesCache.fetch(),
|
colorStripSourcesCache.fetch(),
|
||||||
@@ -340,6 +345,7 @@ const _streamSectionMap = {
|
|||||||
value: [csValueSources],
|
value: [csValueSources],
|
||||||
sync: [csSyncClocks],
|
sync: [csSyncClocks],
|
||||||
weather: [csWeatherSources],
|
weather: [csWeatherSources],
|
||||||
|
home_assistant: [csHASources],
|
||||||
};
|
};
|
||||||
|
|
||||||
type StreamCardRenderer = (stream: any) => string;
|
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: '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: '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: '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 },
|
{ 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: '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: '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: '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 },
|
{ 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 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 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 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 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) })));
|
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||||||
|
|
||||||
@@ -801,6 +810,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
value: _cachedValueSources.length,
|
value: _cachedValueSources.length,
|
||||||
sync: _cachedSyncClocks.length,
|
sync: _cachedSyncClocks.length,
|
||||||
weather: _cachedWeatherSources.length,
|
weather: _cachedWeatherSources.length,
|
||||||
|
home_assistant: _cachedHASources.length,
|
||||||
assets: _cachedAssets.length,
|
assets: _cachedAssets.length,
|
||||||
});
|
});
|
||||||
csRawStreams.reconcile(rawStreamItems);
|
csRawStreams.reconcile(rawStreamItems);
|
||||||
@@ -819,6 +829,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
csValueSources.reconcile(valueItems);
|
csValueSources.reconcile(valueItems);
|
||||||
csSyncClocks.reconcile(syncClockItems);
|
csSyncClocks.reconcile(syncClockItems);
|
||||||
csWeatherSources.reconcile(weatherSourceItems);
|
csWeatherSources.reconcile(weatherSourceItems);
|
||||||
|
csHASources.reconcile(haSourceItems);
|
||||||
csAssets.reconcile(assetItems);
|
csAssets.reconcile(assetItems);
|
||||||
} else {
|
} else {
|
||||||
// First render: build full HTML
|
// 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 === 'value') panelContent = csValueSources.render(valueItems);
|
||||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||||
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
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 === 'assets') panelContent = csAssets.render(assetItems);
|
||||||
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||||||
else panelContent = csStaticStreams.render(staticItems);
|
else panelContent = csStaticStreams.render(staticItems);
|
||||||
@@ -845,11 +857,12 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
container.innerHTML = panels;
|
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)
|
// Event delegation for card actions (replaces inline onclick handlers)
|
||||||
initSyncClockDelegation(container);
|
initSyncClockDelegation(container);
|
||||||
initWeatherSourceDelegation(container);
|
initWeatherSourceDelegation(container);
|
||||||
|
initHASourceDelegation(container);
|
||||||
initAudioSourceDelegation(container);
|
initAudioSourceDelegation(container);
|
||||||
initAssetDelegation(container);
|
initAssetDelegation(container);
|
||||||
|
|
||||||
@@ -869,6 +882,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
'value-sources': 'value',
|
'value-sources': 'value',
|
||||||
'sync-clocks': 'sync',
|
'sync-clocks': 'sync',
|
||||||
'weather-sources': 'weather',
|
'weather-sources': 'weather',
|
||||||
|
'ha-sources': 'home_assistant',
|
||||||
'assets': 'assets',
|
'assets': 'assets',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -413,6 +413,27 @@ export interface WeatherSourceListResponse {
|
|||||||
count: number;
|
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 ────────────────────────────────────────────────────
|
// ── Asset ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Asset {
|
export interface Asset {
|
||||||
|
|||||||
@@ -1784,6 +1784,43 @@
|
|||||||
"weather_source.geo.error": "Geolocation failed",
|
"weather_source.geo.error": "Geolocation failed",
|
||||||
"weather_source.geo.not_supported": "Geolocation is not supported by your browser",
|
"weather_source.geo.not_supported": "Geolocation is not supported by your browser",
|
||||||
"streams.group.weather": "Weather",
|
"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": "Sync Clock:",
|
||||||
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the 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",
|
"graph.title": "Graph",
|
||||||
|
|||||||
@@ -180,6 +180,34 @@ class StartupCondition(Condition):
|
|||||||
return cls()
|
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]] = {
|
_CONDITION_MAP: Dict[str, Type[Condition]] = {
|
||||||
"always": AlwaysCondition,
|
"always": AlwaysCondition,
|
||||||
"application": ApplicationCondition,
|
"application": ApplicationCondition,
|
||||||
@@ -189,6 +217,7 @@ _CONDITION_MAP: Dict[str, Type[Condition]] = {
|
|||||||
"mqtt": MQTTCondition,
|
"mqtt": MQTTCondition,
|
||||||
"webhook": WebhookCondition,
|
"webhook": WebhookCondition,
|
||||||
"startup": StartupCondition,
|
"startup": StartupCondition,
|
||||||
|
"home_assistant": HomeAssistantCondition,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -243,6 +272,10 @@ class Automation:
|
|||||||
deactivation_mode=data.get("deactivation_mode", "none"),
|
deactivation_mode=data.get("deactivation_mode", "none"),
|
||||||
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
|
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
|
||||||
tags=data.get("tags", []),
|
tags=data.get("tags", []),
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
created_at=datetime.fromisoformat(
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
data.get("created_at", datetime.now(timezone.utc).isoformat())
|
||||||
|
),
|
||||||
|
updated_at=datetime.fromisoformat(
|
||||||
|
data.get("updated_at", datetime.now(timezone.utc).isoformat())
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ _ENTITY_TABLES = [
|
|||||||
"gradients",
|
"gradients",
|
||||||
"weather_sources",
|
"weather_sources",
|
||||||
"assets",
|
"assets",
|
||||||
|
"home_assistant_sources",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -96,30 +97,36 @@ class Database:
|
|||||||
"""Create tables if they don't exist."""
|
"""Create tables if they don't exist."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Schema version tracking
|
# Schema version tracking
|
||||||
self._conn.execute("""
|
self._conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
version INTEGER PRIMARY KEY,
|
version INTEGER PRIMARY KEY,
|
||||||
applied_at TEXT NOT NULL
|
applied_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Key-value settings table
|
# Key-value settings table
|
||||||
self._conn.execute("""
|
self._conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Create entity tables
|
# Create entity tables
|
||||||
for table in _ENTITY_TABLES:
|
for table in _ENTITY_TABLES:
|
||||||
self._conn.execute(f"""
|
self._conn.execute(
|
||||||
|
f"""
|
||||||
CREATE TABLE IF NOT EXISTS [{table}] (
|
CREATE TABLE IF NOT EXISTS [{table}] (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
data TEXT NOT NULL
|
data TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
f"CREATE INDEX IF NOT EXISTS idx_{table}_name ON [{table}](name)"
|
f"CREATE INDEX IF NOT EXISTS idx_{table}_name ON [{table}](name)"
|
||||||
)
|
)
|
||||||
@@ -131,6 +138,7 @@ class Database:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if not existing:
|
if not existing:
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (?, ?)",
|
"INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||||
(_SCHEMA_VERSION, datetime.now(timezone.utc).isoformat()),
|
(_SCHEMA_VERSION, datetime.now(timezone.utc).isoformat()),
|
||||||
@@ -181,9 +189,7 @@ class Database:
|
|||||||
"""
|
"""
|
||||||
_check_table(table)
|
_check_table(table)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
rows = self._conn.execute(
|
rows = self._conn.execute(f"SELECT id, data FROM [{table}]").fetchall()
|
||||||
f"SELECT id, data FROM [{table}]"
|
|
||||||
).fetchall()
|
|
||||||
result = []
|
result = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
@@ -218,9 +224,7 @@ class Database:
|
|||||||
if _writes_frozen:
|
if _writes_frozen:
|
||||||
return
|
return
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._conn.execute(
|
self._conn.execute(f"DELETE FROM [{table}] WHERE id = ?", (item_id,))
|
||||||
f"DELETE FROM [{table}] WHERE id = ?", (item_id,)
|
|
||||||
)
|
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
def delete_all(self, table: str) -> None:
|
def delete_all(self, table: str) -> None:
|
||||||
@@ -254,9 +258,7 @@ class Database:
|
|||||||
"""Count rows in an entity table."""
|
"""Count rows in an entity table."""
|
||||||
_check_table(table)
|
_check_table(table)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(f"SELECT COUNT(*) as cnt FROM [{table}]").fetchone()
|
||||||
f"SELECT COUNT(*) as cnt FROM [{table}]"
|
|
||||||
).fetchone()
|
|
||||||
return row["cnt"]
|
return row["cnt"]
|
||||||
|
|
||||||
def table_exists_with_data(self, table: str) -> bool:
|
def table_exists_with_data(self, table: str) -> bool:
|
||||||
@@ -264,9 +266,7 @@ class Database:
|
|||||||
_check_table(table)
|
_check_table(table)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
try:
|
try:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(f"SELECT COUNT(*) as cnt FROM [{table}]").fetchone()
|
||||||
f"SELECT COUNT(*) as cnt FROM [{table}]"
|
|
||||||
).fetchone()
|
|
||||||
return row["cnt"] > 0
|
return row["cnt"] > 0
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
logger.debug("Table %s does not exist or is inaccessible: %s", table, 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:
|
def get_setting(self, key: str) -> dict | None:
|
||||||
"""Read a setting by key. Returns parsed JSON dict, or None if not found."""
|
"""Read a setting by key. Returns parsed JSON dict, or None if not found."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||||
"SELECT value FROM settings WHERE key = ?", (key,)
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
87
server/src/wled_controller/storage/home_assistant_source.py
Normal file
87
server/src/wled_controller/storage/home_assistant_source.py
Normal 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 [],
|
||||||
|
)
|
||||||
101
server/src/wled_controller/storage/home_assistant_store.py
Normal file
101
server/src/wled_controller/storage/home_assistant_store.py
Normal 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
|
||||||
@@ -213,6 +213,7 @@
|
|||||||
{% include 'modals/test-value-source.html' %}
|
{% include 'modals/test-value-source.html' %}
|
||||||
{% include 'modals/sync-clock-editor.html' %}
|
{% include 'modals/sync-clock-editor.html' %}
|
||||||
{% include 'modals/weather-source-editor.html' %}
|
{% include 'modals/weather-source-editor.html' %}
|
||||||
|
{% include 'modals/ha-source-editor.html' %}
|
||||||
{% include 'modals/asset-upload.html' %}
|
{% include 'modals/asset-upload.html' %}
|
||||||
{% include 'modals/asset-editor.html' %}
|
{% include 'modals/asset-editor.html' %}
|
||||||
{% include 'modals/settings.html' %}
|
{% include 'modals/settings.html' %}
|
||||||
|
|||||||
@@ -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">✕</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">✕</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">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user