Add runtime script management with Home Assistant integration

Features:
- Runtime script CRUD operations (create, update, delete)
- Thread-safe ConfigManager for YAML updates
- WebSocket notifications for script changes
- Web UI script management interface with full CRUD
- Home Assistant auto-reload on script changes
- Client-side position interpolation for smooth playback
- Include command field in script list API response

Technical improvements:
- Added broadcast_scripts_changed() to WebSocket manager
- Enhanced HA integration to handle scripts_changed messages
- Implemented smooth position updates in Web UI (100ms interval)
- Thread-safe configuration updates with file locking

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 03:53:23 +03:00
parent 71a0a6e6d1
commit d7c5994e56
8 changed files with 1013 additions and 26 deletions

View File

@@ -2,6 +2,7 @@
import asyncio
import logging
import re
import subprocess
from typing import Any
@@ -9,7 +10,9 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import settings
from ..config import ScriptConfig, settings
from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
logger = logging.getLogger(__name__)
@@ -37,6 +40,7 @@ class ScriptInfo(BaseModel):
name: str
label: str
command: str
description: str
icon: str | None = None
timeout: int
@@ -53,6 +57,7 @@ async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
ScriptInfo(
name=name,
label=config.label or name.replace("_", " ").title(),
command=config.command,
description=config.description,
icon=config.icon,
timeout=config.timeout,
@@ -166,3 +171,185 @@ def _run_script(
"stdout": "",
"stderr": str(e),
}
# Script management endpoints
class ScriptCreateRequest(BaseModel):
"""Request model for creating or updating a script."""
command: str = Field(..., description="Command to execute", min_length=1)
label: str | None = Field(default=None, description="User-friendly label")
description: str = Field(default="", description="Script description")
icon: str | None = Field(default=None, description="Custom MDI icon (e.g., 'mdi:power')")
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: str | None = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
def _validate_script_name(name: str) -> None:
"""Validate script name.
Args:
name: Script name to validate.
Raises:
HTTPException: If name is invalid.
"""
if not name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Script name cannot be empty",
)
if not re.match(r"^[a-zA-Z0-9_]+$", name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Script name must contain only alphanumeric characters and underscores",
)
if len(name) > 64:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Script name must be 64 characters or less",
)
@router.post("/create/{script_name}")
async def create_script(
script_name: str,
request: ScriptCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Create a new script.
Args:
script_name: Name for the new script (alphanumeric + underscore only).
request: Script configuration.
Returns:
Success response with script name.
Raises:
HTTPException: If script already exists or name is invalid.
"""
# Validate name
_validate_script_name(script_name)
# Check if script already exists
if script_name in settings.scripts:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Script '{script_name}' already exists. Use PUT /api/scripts/update/{script_name} to update it.",
)
# Create script config
script_config = ScriptConfig(**request.model_dump())
# Add to config file and in-memory
try:
config_manager.add_script(script_name, script_config)
except Exception as e:
logger.error(f"Failed to add script '{script_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add script: {str(e)}",
)
# Notify WebSocket clients
await ws_manager.broadcast_scripts_changed()
logger.info(f"Script '{script_name}' created successfully")
return {"success": True, "script": script_name}
@router.put("/update/{script_name}")
async def update_script(
script_name: str,
request: ScriptCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Update an existing script.
Args:
script_name: Name of the script to update.
request: Updated script configuration.
Returns:
Success response with script name.
Raises:
HTTPException: If script does not exist.
"""
# Validate name
_validate_script_name(script_name)
# Check if script exists
if script_name not in settings.scripts:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Script '{script_name}' not found. Use POST /api/scripts/create/{script_name} to create it.",
)
# Create updated script config
script_config = ScriptConfig(**request.model_dump())
# Update config file and in-memory
try:
config_manager.update_script(script_name, script_config)
except Exception as e:
logger.error(f"Failed to update script '{script_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update script: {str(e)}",
)
# Notify WebSocket clients
await ws_manager.broadcast_scripts_changed()
logger.info(f"Script '{script_name}' updated successfully")
return {"success": True, "script": script_name}
@router.delete("/delete/{script_name}")
async def delete_script(
script_name: str,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Delete a script.
Args:
script_name: Name of the script to delete.
Returns:
Success response with script name.
Raises:
HTTPException: If script does not exist.
"""
# Validate name
_validate_script_name(script_name)
# Check if script exists
if script_name not in settings.scripts:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Script '{script_name}' not found",
)
# Delete from config file and in-memory
try:
config_manager.delete_script(script_name)
except Exception as e:
logger.error(f"Failed to delete script '{script_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete script: {str(e)}",
)
# Notify WebSocket clients
await ws_manager.broadcast_scripts_changed()
logger.info(f"Script '{script_name}' deleted successfully")
return {"success": True, "script": script_name}