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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user