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:
@@ -46,6 +46,7 @@ dependencies = [
|
||||
"aiomqtt>=2.0.0",
|
||||
"openrgb-python>=0.2.15",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
"websockets>=13.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -27,6 +27,7 @@ from .routes.gradients import router as gradients_router
|
||||
from .routes.weather_sources import router as weather_sources_router
|
||||
from .routes.update import router as update_router
|
||||
from .routes.assets import router as assets_router
|
||||
from .routes.home_assistant import router as home_assistant_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -54,5 +55,6 @@ router.include_router(gradients_router)
|
||||
router.include_router(weather_sources_router)
|
||||
router.include_router(update_router)
|
||||
router.include_router(assets_router)
|
||||
router.include_router(home_assistant_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -21,7 +21,9 @@ from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import (
|
||||
ColorStripProcessingTemplateStore,
|
||||
)
|
||||
from wled_controller.storage.gradient_store import GradientStore
|
||||
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
||||
from wled_controller.storage.asset_store import AssetStore
|
||||
@@ -30,6 +32,8 @@ from wled_controller.core.weather.weather_manager import WeatherManager
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
from wled_controller.core.update.update_service import UpdateService
|
||||
from wled_controller.storage.home_assistant_store import HomeAssistantStore
|
||||
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -136,6 +140,14 @@ def get_asset_store() -> AssetStore:
|
||||
return _get("asset_store", "Asset store")
|
||||
|
||||
|
||||
def get_ha_store() -> HomeAssistantStore:
|
||||
return _get("ha_store", "Home Assistant store")
|
||||
|
||||
|
||||
def get_ha_manager() -> HomeAssistantManager:
|
||||
return _get("ha_manager", "Home Assistant manager")
|
||||
|
||||
|
||||
def get_database() -> Database:
|
||||
return _get("database", "Database")
|
||||
|
||||
@@ -157,12 +169,14 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
|
||||
"""
|
||||
pm = _deps.get("processor_manager")
|
||||
if pm is not None:
|
||||
pm.fire_event({
|
||||
"type": "entity_changed",
|
||||
"entity_type": entity_type,
|
||||
"action": action,
|
||||
"id": entity_id,
|
||||
})
|
||||
pm.fire_event(
|
||||
{
|
||||
"type": "entity_changed",
|
||||
"entity_type": entity_type,
|
||||
"action": action,
|
||||
"id": entity_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── Initialization ──────────────────────────────────────────────────────
|
||||
@@ -193,31 +207,37 @@ def init_dependencies(
|
||||
weather_manager: WeatherManager | None = None,
|
||||
update_service: UpdateService | None = None,
|
||||
asset_store: AssetStore | None = None,
|
||||
ha_store: HomeAssistantStore | None = None,
|
||||
ha_manager: HomeAssistantManager | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
_deps.update({
|
||||
"database": database,
|
||||
"device_store": device_store,
|
||||
"template_store": template_store,
|
||||
"processor_manager": processor_manager,
|
||||
"pp_template_store": pp_template_store,
|
||||
"pattern_template_store": pattern_template_store,
|
||||
"picture_source_store": picture_source_store,
|
||||
"output_target_store": output_target_store,
|
||||
"color_strip_store": color_strip_store,
|
||||
"audio_source_store": audio_source_store,
|
||||
"audio_template_store": audio_template_store,
|
||||
"value_source_store": value_source_store,
|
||||
"automation_store": automation_store,
|
||||
"scene_preset_store": scene_preset_store,
|
||||
"automation_engine": automation_engine,
|
||||
"auto_backup_engine": auto_backup_engine,
|
||||
"sync_clock_store": sync_clock_store,
|
||||
"sync_clock_manager": sync_clock_manager,
|
||||
"cspt_store": cspt_store,
|
||||
"gradient_store": gradient_store,
|
||||
"weather_source_store": weather_source_store,
|
||||
"weather_manager": weather_manager,
|
||||
"update_service": update_service,
|
||||
"asset_store": asset_store,
|
||||
})
|
||||
_deps.update(
|
||||
{
|
||||
"database": database,
|
||||
"device_store": device_store,
|
||||
"template_store": template_store,
|
||||
"processor_manager": processor_manager,
|
||||
"pp_template_store": pp_template_store,
|
||||
"pattern_template_store": pattern_template_store,
|
||||
"picture_source_store": picture_source_store,
|
||||
"output_target_store": output_target_store,
|
||||
"color_strip_store": color_strip_store,
|
||||
"audio_source_store": audio_source_store,
|
||||
"audio_template_store": audio_template_store,
|
||||
"value_source_store": value_source_store,
|
||||
"automation_store": automation_store,
|
||||
"scene_preset_store": scene_preset_store,
|
||||
"automation_engine": automation_engine,
|
||||
"auto_backup_engine": auto_backup_engine,
|
||||
"sync_clock_store": sync_clock_store,
|
||||
"sync_clock_manager": sync_clock_manager,
|
||||
"cspt_store": cspt_store,
|
||||
"gradient_store": gradient_store,
|
||||
"weather_source_store": weather_source_store,
|
||||
"weather_manager": weather_manager,
|
||||
"update_service": update_service,
|
||||
"asset_store": asset_store,
|
||||
"ha_store": ha_store,
|
||||
"ha_manager": ha_manager,
|
||||
}
|
||||
)
|
||||
|
||||
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_color_strip_store,
|
||||
get_device_store,
|
||||
get_ha_manager,
|
||||
get_ha_store,
|
||||
get_output_target_store,
|
||||
get_pattern_template_store,
|
||||
get_picture_source_store,
|
||||
@@ -311,3 +313,52 @@ def list_api_keys(_: AuthRequired):
|
||||
for label, key in config.auth.api_keys.items()
|
||||
]
|
||||
return {"keys": keys, "count": len(keys)}
|
||||
|
||||
|
||||
@router.get("/api/v1/system/integrations-status", tags=["System"])
|
||||
async def get_integrations_status(
|
||||
_: AuthRequired,
|
||||
ha_store=Depends(get_ha_store),
|
||||
ha_manager=Depends(get_ha_manager),
|
||||
):
|
||||
"""Return connection status for external integrations (MQTT, Home Assistant).
|
||||
|
||||
Used by the dashboard to show connectivity indicators.
|
||||
"""
|
||||
from wled_controller.core.devices.mqtt_client import get_mqtt_service
|
||||
|
||||
# MQTT status
|
||||
mqtt_service = get_mqtt_service()
|
||||
mqtt_config = get_config().mqtt
|
||||
mqtt_status = {
|
||||
"enabled": mqtt_config.enabled,
|
||||
"connected": mqtt_service.is_connected if mqtt_service else False,
|
||||
"broker": (
|
||||
f"{mqtt_config.broker_host}:{mqtt_config.broker_port}" if mqtt_config.enabled else None
|
||||
),
|
||||
}
|
||||
|
||||
# Home Assistant status
|
||||
ha_sources = ha_store.get_all_sources()
|
||||
ha_connections = ha_manager.get_connection_status()
|
||||
ha_status_map = {s["source_id"]: s for s in ha_connections}
|
||||
ha_items = []
|
||||
for source in ha_sources:
|
||||
status = ha_status_map.get(source.id)
|
||||
ha_items.append(
|
||||
{
|
||||
"source_id": source.id,
|
||||
"name": source.name,
|
||||
"connected": status["connected"] if status else False,
|
||||
"entity_count": status["entity_count"] if status else 0,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"mqtt": mqtt_status,
|
||||
"home_assistant": {
|
||||
"sources": ha_items,
|
||||
"total": len(ha_sources),
|
||||
"connected": sum(1 for s in ha_items if s["connected"]),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,21 +12,41 @@ class ConditionSchema(BaseModel):
|
||||
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
||||
# Application condition fields
|
||||
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
|
||||
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
|
||||
match_type: Optional[str] = Field(
|
||||
None, description="'running' or 'topmost' (for application condition)"
|
||||
)
|
||||
# Time-of-day condition fields
|
||||
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day condition)")
|
||||
start_time: Optional[str] = Field(
|
||||
None, description="Start time HH:MM (for time_of_day condition)"
|
||||
)
|
||||
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
|
||||
# System idle condition fields
|
||||
idle_minutes: Optional[int] = Field(None, description="Idle timeout in minutes (for system_idle condition)")
|
||||
when_idle: Optional[bool] = Field(None, description="True=active when idle (for system_idle condition)")
|
||||
idle_minutes: Optional[int] = Field(
|
||||
None, description="Idle timeout in minutes (for system_idle condition)"
|
||||
)
|
||||
when_idle: Optional[bool] = Field(
|
||||
None, description="True=active when idle (for system_idle condition)"
|
||||
)
|
||||
# Display state condition fields
|
||||
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
|
||||
# MQTT condition fields
|
||||
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
|
||||
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
|
||||
match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)")
|
||||
match_mode: Optional[str] = Field(
|
||||
None, description="'exact', 'contains', or 'regex' (for mqtt condition)"
|
||||
)
|
||||
# Webhook condition fields
|
||||
token: Optional[str] = Field(None, description="Secret token for webhook URL (for webhook condition)")
|
||||
token: Optional[str] = Field(
|
||||
None, description="Secret token for webhook URL (for webhook condition)"
|
||||
)
|
||||
# Home Assistant condition fields
|
||||
ha_source_id: Optional[str] = Field(
|
||||
None, description="Home Assistant source ID (for home_assistant condition)"
|
||||
)
|
||||
entity_id: Optional[str] = Field(
|
||||
None,
|
||||
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant condition)",
|
||||
)
|
||||
|
||||
|
||||
class AutomationCreate(BaseModel):
|
||||
@@ -35,10 +55,16 @@ class AutomationCreate(BaseModel):
|
||||
name: str = Field(description="Automation name", min_length=1, max_length=100)
|
||||
enabled: bool = Field(default=True, description="Whether the automation is enabled")
|
||||
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
|
||||
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
|
||||
conditions: List[ConditionSchema] = Field(
|
||||
default_factory=list, description="List of conditions"
|
||||
)
|
||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||
deactivation_mode: str = Field(default="none", description="'none', 'revert', or 'fallback_scene'")
|
||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
|
||||
deactivation_mode: str = Field(
|
||||
default="none", description="'none', 'revert', or 'fallback_scene'"
|
||||
)
|
||||
deactivation_scene_preset_id: Optional[str] = Field(
|
||||
None, description="Scene preset for fallback deactivation"
|
||||
)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
@@ -47,11 +73,17 @@ class AutomationUpdate(BaseModel):
|
||||
|
||||
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
|
||||
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
|
||||
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
|
||||
condition_logic: Optional[str] = Field(
|
||||
None, description="How conditions combine: 'or' or 'and'"
|
||||
)
|
||||
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
|
||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||
deactivation_mode: Optional[str] = Field(None, description="'none', 'revert', or 'fallback_scene'")
|
||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
|
||||
deactivation_mode: Optional[str] = Field(
|
||||
None, description="'none', 'revert', or 'fallback_scene'"
|
||||
)
|
||||
deactivation_scene_preset_id: Optional[str] = Field(
|
||||
None, description="Scene preset for fallback deactivation"
|
||||
)
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
@@ -67,10 +99,16 @@ class AutomationResponse(BaseModel):
|
||||
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
webhook_url: Optional[str] = Field(None, description="Webhook URL for the first webhook condition (if any)")
|
||||
webhook_url: Optional[str] = Field(
|
||||
None, description="Webhook URL for the first webhook condition (if any)"
|
||||
)
|
||||
is_active: bool = Field(default=False, description="Whether the automation is currently active")
|
||||
last_activated_at: Optional[datetime] = Field(None, description="Last time this automation was activated")
|
||||
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this automation was deactivated")
|
||||
last_activated_at: Optional[datetime] = Field(
|
||||
None, description="Last time this automation was activated"
|
||||
)
|
||||
last_deactivated_at: Optional[datetime] = Field(
|
||||
None, description="Last time this automation was deactivated"
|
||||
)
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
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,
|
||||
Condition,
|
||||
DisplayStateCondition,
|
||||
HomeAssistantCondition,
|
||||
MQTTCondition,
|
||||
StartupCondition,
|
||||
SystemIdleCondition,
|
||||
@@ -37,6 +38,7 @@ class AutomationEngine:
|
||||
scene_preset_store=None,
|
||||
target_store=None,
|
||||
device_store=None,
|
||||
ha_manager=None,
|
||||
):
|
||||
self._store = automation_store
|
||||
self._manager = processor_manager
|
||||
@@ -46,6 +48,7 @@ class AutomationEngine:
|
||||
self._scene_preset_store = scene_preset_store
|
||||
self._target_store = target_store
|
||||
self._device_store = device_store
|
||||
self._ha_manager = ha_manager
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._eval_lock = asyncio.Lock()
|
||||
|
||||
@@ -101,8 +104,12 @@ class AutomationEngine:
|
||||
await self._evaluate_all_locked()
|
||||
|
||||
def _detect_all_sync(
|
||||
self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool,
|
||||
needs_idle: bool, needs_display_state: bool,
|
||||
self,
|
||||
needs_running: bool,
|
||||
needs_topmost: bool,
|
||||
needs_fullscreen: bool,
|
||||
needs_idle: bool,
|
||||
needs_display_state: bool,
|
||||
) -> tuple:
|
||||
"""Run all platform detection in a single thread call.
|
||||
|
||||
@@ -115,10 +122,21 @@ class AutomationEngine:
|
||||
topmost_proc, topmost_fullscreen = self._detector._get_topmost_process_sync()
|
||||
else:
|
||||
topmost_proc, topmost_fullscreen = None, False
|
||||
fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
|
||||
fullscreen_procs = (
|
||||
self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
|
||||
)
|
||||
idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None
|
||||
display_state = self._detector._get_display_power_state_sync() if needs_display_state else None
|
||||
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state
|
||||
display_state = (
|
||||
self._detector._get_display_power_state_sync() if needs_display_state else None
|
||||
)
|
||||
return (
|
||||
running_procs,
|
||||
topmost_proc,
|
||||
topmost_fullscreen,
|
||||
fullscreen_procs,
|
||||
idle_seconds,
|
||||
display_state,
|
||||
)
|
||||
|
||||
async def _evaluate_all_locked(self) -> None:
|
||||
automations = self._store.get_all_automations()
|
||||
@@ -148,24 +166,37 @@ class AutomationEngine:
|
||||
|
||||
# Single executor call for all platform detection
|
||||
loop = asyncio.get_event_loop()
|
||||
(running_procs, topmost_proc, topmost_fullscreen,
|
||||
fullscreen_procs, idle_seconds, display_state) = (
|
||||
await loop.run_in_executor(
|
||||
None, self._detect_all_sync,
|
||||
needs_running, needs_topmost, needs_fullscreen,
|
||||
needs_idle, needs_display_state,
|
||||
)
|
||||
(
|
||||
running_procs,
|
||||
topmost_proc,
|
||||
topmost_fullscreen,
|
||||
fullscreen_procs,
|
||||
idle_seconds,
|
||||
display_state,
|
||||
) = await loop.run_in_executor(
|
||||
None,
|
||||
self._detect_all_sync,
|
||||
needs_running,
|
||||
needs_topmost,
|
||||
needs_fullscreen,
|
||||
needs_idle,
|
||||
needs_display_state,
|
||||
)
|
||||
|
||||
active_automation_ids = set()
|
||||
|
||||
for automation in automations:
|
||||
should_be_active = (
|
||||
automation.enabled
|
||||
and (len(automation.conditions) == 0
|
||||
or self._evaluate_conditions(
|
||||
automation, running_procs, topmost_proc, topmost_fullscreen,
|
||||
fullscreen_procs, idle_seconds, display_state))
|
||||
should_be_active = automation.enabled and (
|
||||
len(automation.conditions) == 0
|
||||
or self._evaluate_conditions(
|
||||
automation,
|
||||
running_procs,
|
||||
topmost_proc,
|
||||
topmost_fullscreen,
|
||||
fullscreen_procs,
|
||||
idle_seconds,
|
||||
display_state,
|
||||
)
|
||||
)
|
||||
|
||||
is_active = automation.id in self._active_automations
|
||||
@@ -184,15 +215,24 @@ class AutomationEngine:
|
||||
await self._deactivate_automation(aid)
|
||||
|
||||
def _evaluate_conditions(
|
||||
self, automation: Automation, running_procs: Set[str],
|
||||
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
||||
self,
|
||||
automation: Automation,
|
||||
running_procs: Set[str],
|
||||
topmost_proc: Optional[str],
|
||||
topmost_fullscreen: bool,
|
||||
fullscreen_procs: Set[str],
|
||||
idle_seconds: Optional[float], display_state: Optional[str],
|
||||
idle_seconds: Optional[float],
|
||||
display_state: Optional[str],
|
||||
) -> bool:
|
||||
results = [
|
||||
self._evaluate_condition(
|
||||
c, running_procs, topmost_proc, topmost_fullscreen,
|
||||
fullscreen_procs, idle_seconds, display_state,
|
||||
c,
|
||||
running_procs,
|
||||
topmost_proc,
|
||||
topmost_fullscreen,
|
||||
fullscreen_procs,
|
||||
idle_seconds,
|
||||
display_state,
|
||||
)
|
||||
for c in automation.conditions
|
||||
]
|
||||
@@ -202,20 +242,27 @@ class AutomationEngine:
|
||||
return any(results) # "or" is default
|
||||
|
||||
def _evaluate_condition(
|
||||
self, condition: Condition, running_procs: Set[str],
|
||||
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
||||
self,
|
||||
condition: Condition,
|
||||
running_procs: Set[str],
|
||||
topmost_proc: Optional[str],
|
||||
topmost_fullscreen: bool,
|
||||
fullscreen_procs: Set[str],
|
||||
idle_seconds: Optional[float], display_state: Optional[str],
|
||||
idle_seconds: Optional[float],
|
||||
display_state: Optional[str],
|
||||
) -> bool:
|
||||
dispatch = {
|
||||
AlwaysCondition: lambda c: True,
|
||||
StartupCondition: lambda c: True,
|
||||
ApplicationCondition: lambda c: self._evaluate_app_condition(c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs),
|
||||
ApplicationCondition: lambda c: self._evaluate_app_condition(
|
||||
c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
|
||||
),
|
||||
TimeOfDayCondition: lambda c: self._evaluate_time_of_day(c),
|
||||
SystemIdleCondition: lambda c: self._evaluate_idle(c, idle_seconds),
|
||||
DisplayStateCondition: lambda c: self._evaluate_display_state(c, display_state),
|
||||
MQTTCondition: lambda c: self._evaluate_mqtt(c),
|
||||
WebhookCondition: lambda c: self._webhook_states.get(c.token, False),
|
||||
HomeAssistantCondition: lambda c: self._evaluate_home_assistant(c),
|
||||
}
|
||||
handler = dispatch.get(type(condition))
|
||||
if handler is None:
|
||||
@@ -243,7 +290,9 @@ class AutomationEngine:
|
||||
return is_idle if condition.when_idle else not is_idle
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_display_state(condition: DisplayStateCondition, display_state: Optional[str]) -> bool:
|
||||
def _evaluate_display_state(
|
||||
condition: DisplayStateCondition, display_state: Optional[str]
|
||||
) -> bool:
|
||||
if display_state is None:
|
||||
return False
|
||||
return display_state == condition.state
|
||||
@@ -268,6 +317,27 @@ class AutomationEngine:
|
||||
logger.debug("MQTT condition regex error: %s", e)
|
||||
return False
|
||||
|
||||
def _evaluate_home_assistant(self, condition: HomeAssistantCondition) -> bool:
|
||||
if self._ha_manager is None:
|
||||
return False
|
||||
entity_state = self._ha_manager.get_state(condition.ha_source_id, condition.entity_id)
|
||||
if entity_state is None:
|
||||
return False
|
||||
value = entity_state.state
|
||||
matchers = {
|
||||
"exact": lambda: value == condition.state,
|
||||
"contains": lambda: condition.state in value,
|
||||
"regex": lambda: bool(re.search(condition.state, value)),
|
||||
}
|
||||
matcher = matchers.get(condition.match_mode)
|
||||
if matcher is None:
|
||||
return False
|
||||
try:
|
||||
return matcher()
|
||||
except re.error as e:
|
||||
logger.debug("HA condition regex error: %s", e)
|
||||
return False
|
||||
|
||||
def _evaluate_app_condition(
|
||||
self,
|
||||
condition: ApplicationCondition,
|
||||
@@ -289,8 +359,7 @@ class AutomationEngine:
|
||||
and any(app == topmost_proc for app in apps_lower)
|
||||
),
|
||||
"topmost": lambda: (
|
||||
topmost_proc is not None
|
||||
and any(app == topmost_proc for app in apps_lower)
|
||||
topmost_proc is not None and any(app == topmost_proc for app in apps_lower)
|
||||
),
|
||||
}
|
||||
handler = match_handlers.get(condition.match_type)
|
||||
@@ -316,12 +385,15 @@ class AutomationEngine:
|
||||
try:
|
||||
preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
|
||||
except ValueError:
|
||||
logger.warning(f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found")
|
||||
logger.warning(
|
||||
f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found"
|
||||
)
|
||||
return
|
||||
|
||||
# For "revert" mode, capture current state before activating
|
||||
if automation.deactivation_mode == "revert":
|
||||
from wled_controller.core.scenes.scene_activator import capture_current_snapshot
|
||||
|
||||
targets = capture_current_snapshot(self._target_store, self._manager)
|
||||
self._pre_activation_snapshots[automation.id] = ScenePreset(
|
||||
id=f"_revert_{automation.id}",
|
||||
@@ -331,8 +403,11 @@ class AutomationEngine:
|
||||
|
||||
# Apply the scene
|
||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||
|
||||
status, errors = await apply_scene_state(
|
||||
preset, self._target_store, self._manager,
|
||||
preset,
|
||||
self._target_store,
|
||||
self._manager,
|
||||
)
|
||||
|
||||
self._active_automations[automation.id] = True
|
||||
@@ -374,8 +449,11 @@ class AutomationEngine:
|
||||
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||
if snapshot and self._target_store:
|
||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||
|
||||
status, errors = await apply_scene_state(
|
||||
snapshot, self._target_store, self._manager,
|
||||
snapshot,
|
||||
self._target_store,
|
||||
self._manager,
|
||||
)
|
||||
if errors:
|
||||
logger.warning(f"Automation {automation_id} revert errors: {errors}")
|
||||
@@ -391,25 +469,34 @@ class AutomationEngine:
|
||||
try:
|
||||
fallback = self._scene_preset_store.get_preset(fallback_id)
|
||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||
|
||||
status, errors = await apply_scene_state(
|
||||
fallback, self._target_store, self._manager,
|
||||
fallback,
|
||||
self._target_store,
|
||||
self._manager,
|
||||
)
|
||||
if errors:
|
||||
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
|
||||
else:
|
||||
logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)")
|
||||
logger.info(
|
||||
f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)"
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found")
|
||||
logger.warning(
|
||||
f"Automation {automation_id}: fallback scene {fallback_id} not found"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
|
||||
|
||||
def _fire_event(self, automation_id: str, action: str) -> None:
|
||||
try:
|
||||
self._manager.fire_event({
|
||||
"type": "automation_state_changed",
|
||||
"automation_id": automation_id,
|
||||
"action": action,
|
||||
})
|
||||
self._manager.fire_event(
|
||||
{
|
||||
"type": "automation_state_changed",
|
||||
"automation_id": automation_id,
|
||||
"action": action,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Automation action failed: %s", e, exc_info=True)
|
||||
|
||||
|
||||
@@ -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.core.processing.sync_clock_manager import SyncClockManager
|
||||
from wled_controller.core.weather.weather_manager import WeatherManager
|
||||
from wled_controller.storage.home_assistant_store import HomeAssistantStore
|
||||
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
||||
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
||||
@@ -95,6 +97,8 @@ asset_store.import_prebuilt_sounds(_prebuilt_sounds_dir)
|
||||
|
||||
sync_clock_manager = SyncClockManager(sync_clock_store)
|
||||
weather_manager = WeatherManager(weather_source_store)
|
||||
ha_store = HomeAssistantStore(db)
|
||||
ha_manager = HomeAssistantManager(ha_store)
|
||||
|
||||
processor_manager = ProcessorManager(
|
||||
ProcessorDependencies(
|
||||
@@ -160,6 +164,7 @@ async def lifespan(app: FastAPI):
|
||||
scene_preset_store=scene_preset_store,
|
||||
target_store=output_target_store,
|
||||
device_store=device_store,
|
||||
ha_manager=ha_manager,
|
||||
)
|
||||
|
||||
# Create auto-backup engine — derive paths from database location so that
|
||||
@@ -208,6 +213,8 @@ async def lifespan(app: FastAPI):
|
||||
weather_manager=weather_manager,
|
||||
update_service=update_service,
|
||||
asset_store=asset_store,
|
||||
ha_store=ha_store,
|
||||
ha_manager=ha_manager,
|
||||
)
|
||||
|
||||
# Register devices in processor manager for health monitoring
|
||||
@@ -274,6 +281,12 @@ async def lifespan(app: FastAPI):
|
||||
# where no CRUD happened during the session.
|
||||
_save_all_stores()
|
||||
|
||||
# Stop Home Assistant manager
|
||||
try:
|
||||
await ha_manager.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping Home Assistant manager: {e}")
|
||||
|
||||
# Stop weather manager
|
||||
try:
|
||||
weather_manager.shutdown()
|
||||
|
||||
@@ -89,3 +89,5 @@ export const fileAudio = '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4
|
||||
export const packageIcon = '<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>';
|
||||
export const heart = '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>';
|
||||
export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>';
|
||||
export const home = '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
|
||||
export const lock = '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>';
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DataCache } from './cache.ts';
|
||||
import type {
|
||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||
SyncClock, WeatherSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||
SyncClock, WeatherSource, HomeAssistantSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||
} from '../types.ts';
|
||||
@@ -226,6 +226,7 @@ export let _cachedValueSources: ValueSource[] = [];
|
||||
// Sync clocks
|
||||
export let _cachedSyncClocks: SyncClock[] = [];
|
||||
export let _cachedWeatherSources: WeatherSource[] = [];
|
||||
export let _cachedHASources: HomeAssistantSource[] = [];
|
||||
export let _cachedAssets: Asset[] = [];
|
||||
|
||||
// Automations
|
||||
@@ -290,6 +291,12 @@ export const weatherSourcesCache = new DataCache<WeatherSource[]>({
|
||||
});
|
||||
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
|
||||
|
||||
export const haSourcesCache = new DataCache<HomeAssistantSource[]>({
|
||||
endpoint: '/home-assistant/sources',
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
haSourcesCache.subscribe(v => { _cachedHASources = v; });
|
||||
|
||||
export const assetsCache = new DataCache<Asset[]>({
|
||||
endpoint: '/assets',
|
||||
extractData: json => json.assets || [],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Automations — automation cards, editor, condition builder, process picker, scene selector.
|
||||
*/
|
||||
|
||||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.ts';
|
||||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
|
||||
@@ -248,6 +248,7 @@ const CONDITION_PILL_RENDERERS: Record<string, ConditionPillRenderer> = {
|
||||
},
|
||||
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
|
||||
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.condition.webhook')}</span>`,
|
||||
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.condition.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
|
||||
};
|
||||
|
||||
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
||||
@@ -515,11 +516,11 @@ export function addAutomationCondition() {
|
||||
_autoGenerateAutomationName();
|
||||
}
|
||||
|
||||
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook'];
|
||||
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
|
||||
const CONDITION_TYPE_ICONS = {
|
||||
always: P.refreshCw, startup: P.power, application: P.smartphone,
|
||||
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
|
||||
mqtt: P.radio, webhook: P.globe,
|
||||
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
|
||||
};
|
||||
|
||||
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
|
||||
@@ -726,6 +727,44 @@ function addAutomationConditionRow(condition: any) {
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
if (type === 'home_assistant') {
|
||||
const haSourceId = data.ha_source_id || '';
|
||||
const entityId = data.entity_id || '';
|
||||
const haState = data.state || '';
|
||||
const matchMode = data.match_mode || 'exact';
|
||||
// Build HA source options from cached data
|
||||
const haOptions = _cachedHASources.map((s: any) =>
|
||||
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
container.innerHTML = `
|
||||
<div class="condition-fields">
|
||||
<small class="condition-always-desc">${t('automations.condition.home_assistant.hint')}</small>
|
||||
<div class="condition-field">
|
||||
<label>${t('automations.condition.home_assistant.ha_source')}</label>
|
||||
<select class="condition-ha-source-id">
|
||||
<option value="">—</option>
|
||||
${haOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('automations.condition.home_assistant.entity_id')}</label>
|
||||
<input type="text" class="condition-ha-entity-id" value="${escapeHtml(entityId)}" placeholder="binary_sensor.front_door">
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('automations.condition.home_assistant.state')}</label>
|
||||
<input type="text" class="condition-ha-state" value="${escapeHtml(haState)}" placeholder="on">
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('automations.condition.home_assistant.match_mode')}</label>
|
||||
<select class="condition-ha-match-mode">
|
||||
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
|
||||
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
|
||||
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
if (type === 'webhook') {
|
||||
if (data.token) {
|
||||
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
|
||||
@@ -835,6 +874,14 @@ function getAutomationEditorConditions() {
|
||||
const cond: any = { condition_type: 'webhook' };
|
||||
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
|
||||
conditions.push(cond);
|
||||
} else if (condType === 'home_assistant') {
|
||||
conditions.push({
|
||||
condition_type: 'home_assistant',
|
||||
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
|
||||
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLInputElement).value.trim(),
|
||||
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
|
||||
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
|
||||
});
|
||||
} else {
|
||||
const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
|
||||
const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
|
||||
|
||||
@@ -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,
|
||||
_cachedSyncClocks,
|
||||
_cachedWeatherSources,
|
||||
_cachedHASources,
|
||||
_cachedAudioTemplates,
|
||||
_cachedCSPTemplates,
|
||||
_csptModalFilters, set_csptModalFilters,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
_sourcesLoading, set_sourcesLoading,
|
||||
apiKey,
|
||||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, assetsCache, _cachedAssets, filtersCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, haSourcesCache, assetsCache, _cachedAssets, filtersCache,
|
||||
colorStripSourcesCache,
|
||||
csptCache, stripFiltersCache,
|
||||
gradientsCache, GradientEntity,
|
||||
@@ -50,6 +51,7 @@ import { updateSubTabHash } from './tabs.ts';
|
||||
import { createValueSourceCard } from './value-sources.ts';
|
||||
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
||||
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
||||
import { createColorStripCard } from './color-strips.ts';
|
||||
import { initAudioSourceDelegation } from './audio-sources.ts';
|
||||
@@ -98,6 +100,7 @@ const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon:
|
||||
const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }];
|
||||
const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }];
|
||||
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
|
||||
const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }];
|
||||
const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }];
|
||||
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
|
||||
|
||||
@@ -168,6 +171,7 @@ const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.secti
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
|
||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
|
||||
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
|
||||
const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction });
|
||||
const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', gridClass: 'templates-grid', addCardOnclick: "showAssetUploadModal()", keyAttr: 'data-id', emptyKey: 'section.empty.assets', bulkActions: _assetDeleteAction });
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
|
||||
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
|
||||
@@ -282,6 +286,7 @@ export async function loadPictureSources() {
|
||||
valueSourcesCache.fetch(),
|
||||
syncClocksCache.fetch(),
|
||||
weatherSourcesCache.fetch(),
|
||||
haSourcesCache.fetch(),
|
||||
assetsCache.fetch(),
|
||||
audioTemplatesCache.fetch(),
|
||||
colorStripSourcesCache.fetch(),
|
||||
@@ -340,6 +345,7 @@ const _streamSectionMap = {
|
||||
value: [csValueSources],
|
||||
sync: [csSyncClocks],
|
||||
weather: [csWeatherSources],
|
||||
home_assistant: [csHASources],
|
||||
};
|
||||
|
||||
type StreamCardRenderer = (stream: any) => string;
|
||||
@@ -566,6 +572,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
||||
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
|
||||
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
|
||||
{ key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
|
||||
];
|
||||
|
||||
@@ -620,6 +627,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
|
||||
{ key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
|
||||
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
|
||||
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
|
||||
{ key: 'assets', titleKey: 'streams.group.assets', icon: ICON_ASSET, count: _cachedAssets.length },
|
||||
]
|
||||
}
|
||||
@@ -781,6 +789,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||||
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
|
||||
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
|
||||
const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) })));
|
||||
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
|
||||
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||||
|
||||
@@ -801,6 +810,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
value: _cachedValueSources.length,
|
||||
sync: _cachedSyncClocks.length,
|
||||
weather: _cachedWeatherSources.length,
|
||||
home_assistant: _cachedHASources.length,
|
||||
assets: _cachedAssets.length,
|
||||
});
|
||||
csRawStreams.reconcile(rawStreamItems);
|
||||
@@ -819,6 +829,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
csValueSources.reconcile(valueItems);
|
||||
csSyncClocks.reconcile(syncClockItems);
|
||||
csWeatherSources.reconcile(weatherSourceItems);
|
||||
csHASources.reconcile(haSourceItems);
|
||||
csAssets.reconcile(assetItems);
|
||||
} else {
|
||||
// First render: build full HTML
|
||||
@@ -838,6 +849,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
||||
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
|
||||
else if (tab.key === 'assets') panelContent = csAssets.render(assetItems);
|
||||
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||||
else panelContent = csStaticStreams.render(staticItems);
|
||||
@@ -845,11 +857,12 @@ function renderPictureSourcesList(streams: any) {
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csAssets]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csAssets]);
|
||||
|
||||
// Event delegation for card actions (replaces inline onclick handlers)
|
||||
initSyncClockDelegation(container);
|
||||
initWeatherSourceDelegation(container);
|
||||
initHASourceDelegation(container);
|
||||
initAudioSourceDelegation(container);
|
||||
initAssetDelegation(container);
|
||||
|
||||
@@ -869,6 +882,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
'value-sources': 'value',
|
||||
'sync-clocks': 'sync',
|
||||
'weather-sources': 'weather',
|
||||
'ha-sources': 'home_assistant',
|
||||
'assets': 'assets',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -413,6 +413,27 @@ export interface WeatherSourceListResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ── Home Assistant Source ────────────────────────────────────
|
||||
|
||||
export interface HomeAssistantSource {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
use_ssl: boolean;
|
||||
entity_filters: string[];
|
||||
connected: boolean;
|
||||
entity_count: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HomeAssistantSourceListResponse {
|
||||
sources: HomeAssistantSource[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ── Asset ────────────────────────────────────────────────────
|
||||
|
||||
export interface Asset {
|
||||
|
||||
@@ -1784,6 +1784,43 @@
|
||||
"weather_source.geo.error": "Geolocation failed",
|
||||
"weather_source.geo.not_supported": "Geolocation is not supported by your browser",
|
||||
"streams.group.weather": "Weather",
|
||||
"streams.group.home_assistant": "Home Assistant",
|
||||
"ha_source.group.title": "Home Assistant Sources",
|
||||
"ha_source.add": "Add Home Assistant Source",
|
||||
"ha_source.edit": "Edit Home Assistant Source",
|
||||
"ha_source.name": "Name:",
|
||||
"ha_source.name.placeholder": "My Home Assistant",
|
||||
"ha_source.name.hint": "A descriptive name for this Home Assistant connection",
|
||||
"ha_source.host": "Host:",
|
||||
"ha_source.host.hint": "Home Assistant host and port, e.g. 192.168.1.100:8123",
|
||||
"ha_source.token": "Access Token:",
|
||||
"ha_source.token.hint": "Long-Lived Access Token from HA (Profile > Security > Long-Lived Access Tokens)",
|
||||
"ha_source.token.edit_hint": "Leave blank to keep the current token",
|
||||
"ha_source.use_ssl": "Use SSL (wss://)",
|
||||
"ha_source.entity_filters": "Entity Filters (optional):",
|
||||
"ha_source.entity_filters.hint": "Comma-separated glob patterns, e.g. sensor.*, binary_sensor.front_door. Leave empty for all.",
|
||||
"ha_source.description": "Description (optional):",
|
||||
"ha_source.test": "Test Connection",
|
||||
"ha_source.test.success": "Connected",
|
||||
"ha_source.test.failed": "Connection failed",
|
||||
"ha_source.connected": "Connected",
|
||||
"ha_source.disconnected": "Disconnected",
|
||||
"ha_source.error.name_required": "Name is required",
|
||||
"ha_source.error.host_required": "Host is required",
|
||||
"ha_source.error.token_required": "Access token is required",
|
||||
"ha_source.error.load": "Failed to load Home Assistant source",
|
||||
"ha_source.created": "Home Assistant source created",
|
||||
"ha_source.updated": "Home Assistant source updated",
|
||||
"ha_source.deleted": "Home Assistant source deleted",
|
||||
"ha_source.delete.confirm": "Delete this Home Assistant connection?",
|
||||
"section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.",
|
||||
"automations.condition.home_assistant": "Home Assistant",
|
||||
"automations.condition.home_assistant.desc": "HA entity state",
|
||||
"automations.condition.home_assistant.ha_source": "HA Source:",
|
||||
"automations.condition.home_assistant.entity_id": "Entity ID:",
|
||||
"automations.condition.home_assistant.state": "State:",
|
||||
"automations.condition.home_assistant.match_mode": "Match Mode:",
|
||||
"automations.condition.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
|
||||
"color_strip.clock": "Sync Clock:",
|
||||
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
|
||||
"graph.title": "Graph",
|
||||
|
||||
@@ -180,6 +180,34 @@ class StartupCondition(Condition):
|
||||
return cls()
|
||||
|
||||
|
||||
@dataclass
|
||||
class HomeAssistantCondition(Condition):
|
||||
"""Activate based on a Home Assistant entity state."""
|
||||
|
||||
condition_type: str = "home_assistant"
|
||||
ha_source_id: str = "" # references HomeAssistantSource
|
||||
entity_id: str = "" # e.g. "binary_sensor.front_door"
|
||||
state: str = "" # expected state value
|
||||
match_mode: str = "exact" # "exact" | "contains" | "regex"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["ha_source_id"] = self.ha_source_id
|
||||
d["entity_id"] = self.entity_id
|
||||
d["state"] = self.state
|
||||
d["match_mode"] = self.match_mode
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "HomeAssistantCondition":
|
||||
return cls(
|
||||
ha_source_id=data.get("ha_source_id", ""),
|
||||
entity_id=data.get("entity_id", ""),
|
||||
state=data.get("state", ""),
|
||||
match_mode=data.get("match_mode", "exact"),
|
||||
)
|
||||
|
||||
|
||||
_CONDITION_MAP: Dict[str, Type[Condition]] = {
|
||||
"always": AlwaysCondition,
|
||||
"application": ApplicationCondition,
|
||||
@@ -189,6 +217,7 @@ _CONDITION_MAP: Dict[str, Type[Condition]] = {
|
||||
"mqtt": MQTTCondition,
|
||||
"webhook": WebhookCondition,
|
||||
"startup": StartupCondition,
|
||||
"home_assistant": HomeAssistantCondition,
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +272,10 @@ class Automation:
|
||||
deactivation_mode=data.get("deactivation_mode", "none"),
|
||||
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
|
||||
tags=data.get("tags", []),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
||||
created_at=datetime.fromisoformat(
|
||||
data.get("created_at", datetime.now(timezone.utc).isoformat())
|
||||
),
|
||||
updated_at=datetime.fromisoformat(
|
||||
data.get("updated_at", datetime.now(timezone.utc).isoformat())
|
||||
),
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ _ENTITY_TABLES = [
|
||||
"gradients",
|
||||
"weather_sources",
|
||||
"assets",
|
||||
"home_assistant_sources",
|
||||
]
|
||||
|
||||
|
||||
@@ -96,30 +97,36 @@ class Database:
|
||||
"""Create tables if they don't exist."""
|
||||
with self._lock:
|
||||
# Schema version tracking
|
||||
self._conn.execute("""
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# Key-value settings table
|
||||
self._conn.execute("""
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# Create entity tables
|
||||
for table in _ENTITY_TABLES:
|
||||
self._conn.execute(f"""
|
||||
self._conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS [{table}] (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
data TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
"""
|
||||
)
|
||||
self._conn.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_{table}_name ON [{table}](name)"
|
||||
)
|
||||
@@ -131,6 +138,7 @@ class Database:
|
||||
).fetchone()
|
||||
if not existing:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
self._conn.execute(
|
||||
"INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||
(_SCHEMA_VERSION, datetime.now(timezone.utc).isoformat()),
|
||||
@@ -181,9 +189,7 @@ class Database:
|
||||
"""
|
||||
_check_table(table)
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
f"SELECT id, data FROM [{table}]"
|
||||
).fetchall()
|
||||
rows = self._conn.execute(f"SELECT id, data FROM [{table}]").fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
try:
|
||||
@@ -218,9 +224,7 @@ class Database:
|
||||
if _writes_frozen:
|
||||
return
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
f"DELETE FROM [{table}] WHERE id = ?", (item_id,)
|
||||
)
|
||||
self._conn.execute(f"DELETE FROM [{table}] WHERE id = ?", (item_id,))
|
||||
self._conn.commit()
|
||||
|
||||
def delete_all(self, table: str) -> None:
|
||||
@@ -254,9 +258,7 @@ class Database:
|
||||
"""Count rows in an entity table."""
|
||||
_check_table(table)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
f"SELECT COUNT(*) as cnt FROM [{table}]"
|
||||
).fetchone()
|
||||
row = self._conn.execute(f"SELECT COUNT(*) as cnt FROM [{table}]").fetchone()
|
||||
return row["cnt"]
|
||||
|
||||
def table_exists_with_data(self, table: str) -> bool:
|
||||
@@ -264,9 +266,7 @@ class Database:
|
||||
_check_table(table)
|
||||
with self._lock:
|
||||
try:
|
||||
row = self._conn.execute(
|
||||
f"SELECT COUNT(*) as cnt FROM [{table}]"
|
||||
).fetchone()
|
||||
row = self._conn.execute(f"SELECT COUNT(*) as cnt FROM [{table}]").fetchone()
|
||||
return row["cnt"] > 0
|
||||
except sqlite3.OperationalError as e:
|
||||
logger.debug("Table %s does not exist or is inaccessible: %s", table, e)
|
||||
@@ -277,9 +277,7 @@ class Database:
|
||||
def get_setting(self, key: str) -> dict | None:
|
||||
"""Read a setting by key. Returns parsed JSON dict, or None if not found."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT value FROM settings WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
row = self._conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
|
||||
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/sync-clock-editor.html' %}
|
||||
{% include 'modals/weather-source-editor.html' %}
|
||||
{% include 'modals/ha-source-editor.html' %}
|
||||
{% include 'modals/asset-upload.html' %}
|
||||
{% include 'modals/asset-editor.html' %}
|
||||
{% include 'modals/settings.html' %}
|
||||
|
||||
@@ -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