Files
core-api/src/controllers/housekeeping_controller.py
T
jpmschweitzerandClaude Opus 4.5 fd8aee3227 Fix device control response returning stale state
Add 300ms delay after executing device action before fetching new state,
allowing Home Assistant time to update the entity state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:40:19 +01:00

749 lines
25 KiB
Python

"""
Housekeeping Controller
Provides API endpoints for home automation via Home Assistant.
Designed for the Tatlock Housekeeper agent and other consumers.
"""
import asyncio
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from src.controllers.base import BaseController
from src.clients.homeassistant_client import get_homeassistant_client
from src.logging_config import get_logger
from src.auth.oidc import get_admin_user
logger = get_logger(__name__)
# ========================================================================
# Pydantic Schemas
# ========================================================================
class Device(BaseModel):
"""Device/entity information"""
entity_id: str
name: str
domain: str
area: Optional[str] = None
state: str
attributes: Dict[str, Any] = {}
last_changed: Optional[str] = None
class DeviceListResponse(BaseModel):
"""Response for device listing"""
devices: List[Device]
class DeviceDetailResponse(Device):
"""Detailed device response"""
pass
class Area(BaseModel):
"""Area/room information"""
id: str
name: str
class AreaListResponse(BaseModel):
"""Response for area listing"""
areas: List[Area]
class DeviceControlRequest(BaseModel):
"""Request to control a device"""
action: str = Field(..., description="Action: turn_on, turn_off, or toggle")
brightness: Optional[int] = Field(None, ge=0, le=255)
color_temp: Optional[int] = None
rgb_color: Optional[List[int]] = None
class Config:
extra = "allow" # Allow additional attributes
class DeviceControlResponse(BaseModel):
"""Response from device control"""
success: bool
entity_id: str
new_state: Optional[str] = None
message: str
class Scene(BaseModel):
"""Scene information"""
id: str
name: str
class SceneListResponse(BaseModel):
"""Response for scene listing"""
scenes: List[Scene]
class SceneActivateResponse(BaseModel):
"""Response from scene activation"""
success: bool
scene_id: str
message: str
class Script(BaseModel):
"""Script information"""
id: str
name: str
class ScriptListResponse(BaseModel):
"""Response for script listing"""
scripts: List[Script]
class ScriptRunRequest(BaseModel):
"""Request to run a script"""
variables: Optional[Dict[str, Any]] = None
class ScriptRunResponse(BaseModel):
"""Response from script execution"""
success: bool
script_id: str
message: str
class Automation(BaseModel):
"""Automation information"""
id: str
name: str
enabled: bool
class AutomationListResponse(BaseModel):
"""Response for automation listing"""
automations: List[Automation]
class AutomationToggleRequest(BaseModel):
"""Request to toggle automation"""
enabled: bool
class AutomationToggleResponse(BaseModel):
"""Response from automation toggle"""
success: bool
automation_id: str
enabled: bool
message: str
class HistoryEntry(BaseModel):
"""Single history entry"""
state: str
timestamp: str
attributes: Dict[str, Any] = {}
class HistoryResponse(BaseModel):
"""Response for history query"""
entity_id: str
history: List[HistoryEntry]
class HealthResponse(BaseModel):
"""Health check response"""
status: str
connected: bool
platform: str
version: Optional[str] = None
error: Optional[str] = None
class ErrorResponse(BaseModel):
"""Standard error response"""
error: bool = True
code: str
message: str
# ========================================================================
# Controller
# ========================================================================
class HousekeepingController(BaseController):
"""
Controller for home automation operations
Provides endpoints for:
- Device discovery and control
- Scene activation
- Script execution
- Automation management
- State history
"""
def __init__(self):
super().__init__(prefix="/housekeeping", tags=["Housekeeping"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
# ====================================================================
# Health
# ====================================================================
@router.get(
"/health",
response_model=HealthResponse,
summary="Home automation health check"
)
async def get_health():
"""
Check Home Assistant connection health
Returns connection status and HA version.
"""
ha = get_homeassistant_client()
return await ha.health_check()
# ====================================================================
# Device Discovery
# ====================================================================
@router.get(
"/devices",
response_model=DeviceListResponse,
summary="List available devices"
)
async def list_devices(
domain: Optional[str] = Query(None, description="Filter by domain (light, switch, climate, etc.)"),
area: Optional[str] = Query(None, description="Filter by area/room name")
):
"""
List all available devices with optional filtering
Query Parameters:
- domain: Filter by device type (light, switch, climate, media_player, etc.)
- area: Filter by area/room name
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
# Non-controllable domains to filter out
excluded_domains = {
"zone", "person", "device_tracker", "sun", "weather",
"persistent_notification", "update", "binary_sensor", "sensor",
"conversation", "calendar", "button", "number", "select",
"text", "time", "date", "datetime", "image", "tts", "stt"
}
devices = []
for state in states:
entity_id = state.get("entity_id", "")
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
# Skip non-controllable entities
if entity_domain in excluded_domains:
continue
# Apply domain filter
if domain and entity_domain != domain:
continue
# Get area from attributes
device_area = state.get("attributes", {}).get("area_id")
# Apply area filter
if area and device_area and area.lower() not in device_area.lower():
continue
device = Device(
entity_id=entity_id,
name=state.get("attributes", {}).get("friendly_name", entity_id),
domain=entity_domain,
area=device_area,
state=state.get("state", "unknown"),
attributes=state.get("attributes", {}),
last_changed=state.get("last_changed")
)
devices.append(device)
return DeviceListResponse(devices=devices)
except Exception as e:
logger.error(f"Failed to list devices: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/devices/{entity_id:path}",
response_model=DeviceDetailResponse,
responses={404: {"model": ErrorResponse}}
)
async def get_device(entity_id: str):
"""
Get detailed state of a specific device
Args:
entity_id: Entity ID (e.g., light.living_room)
"""
ha = get_homeassistant_client()
try:
state = await ha.get_state(entity_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "DEVICE_NOT_FOUND",
"message": f"Device {entity_id} not found"}
)
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
return DeviceDetailResponse(
entity_id=entity_id,
name=state.get("attributes", {}).get("friendly_name", entity_id),
domain=entity_domain,
area=state.get("attributes", {}).get("area_id"),
state=state.get("state", "unknown"),
attributes=state.get("attributes", {}),
last_changed=state.get("last_changed")
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get device {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/areas",
response_model=AreaListResponse,
summary="List areas/rooms"
)
async def list_areas():
"""
List all configured areas/rooms in Home Assistant
"""
ha = get_homeassistant_client()
try:
areas = await ha.get_areas()
return AreaListResponse(
areas=[Area(id=a["id"], name=a["name"]) for a in areas]
)
except Exception as e:
logger.error(f"Failed to list areas: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Device Control
# ====================================================================
@router.post(
"/devices/{entity_id:path}/control",
response_model=DeviceControlResponse,
responses={404: {"model": ErrorResponse}, 400: {"model": ErrorResponse}}
)
async def control_device(
entity_id: str,
request: DeviceControlRequest,
user: Dict = Depends(get_admin_user)
):
"""
Control a device (turn on, turn off, toggle, or set attributes)
Args:
entity_id: Entity ID (e.g., light.living_room)
request: Control request with action and optional attributes
"""
ha = get_homeassistant_client()
# Validate action
valid_actions = ["turn_on", "turn_off", "toggle"]
if request.action not in valid_actions:
raise HTTPException(
status_code=400,
detail={"error": True, "code": "INVALID_ACTION",
"message": f"Invalid action '{request.action}'. Must be one of: {', '.join(valid_actions)}"}
)
try:
# Check device exists first
current_state = await ha.get_state(entity_id)
if not current_state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "DEVICE_NOT_FOUND",
"message": f"Device {entity_id} not found"}
)
# Build attributes dict from request
attributes = {}
if request.brightness is not None:
attributes["brightness"] = request.brightness
if request.color_temp is not None:
attributes["color_temp"] = request.color_temp
if request.rgb_color is not None:
attributes["rgb_color"] = request.rgb_color
# Add any extra attributes from request
extra_fields = request.model_dump(exclude={"action", "brightness", "color_temp", "rgb_color"})
for key, value in extra_fields.items():
if value is not None:
attributes[key] = value
# Execute action
if request.action == "turn_on":
await ha.turn_on(entity_id, **attributes)
elif request.action == "turn_off":
await ha.turn_off(entity_id)
else: # toggle
await ha.toggle(entity_id)
# Wait for HA to update state before fetching
await asyncio.sleep(0.3)
# Get new state
new_state = await ha.get_state(entity_id)
logger.info(f"Device {entity_id} controlled: {request.action} by {user.get('preferred_username', 'unknown')}")
return DeviceControlResponse(
success=True,
entity_id=entity_id,
new_state=new_state.get("state") if new_state else None,
message=f"Device {request.action} successful"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to control device {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Scenes
# ====================================================================
@router.get(
"/scenes",
response_model=SceneListResponse,
summary="List available scenes"
)
async def list_scenes():
"""
List all available scenes in Home Assistant
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
scenes = [
Scene(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
)
for s in states
if s["entity_id"].startswith("scene.")
]
return SceneListResponse(scenes=scenes)
except Exception as e:
logger.error(f"Failed to list scenes: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/scenes/{scene_id:path}/activate",
response_model=SceneActivateResponse,
responses={404: {"model": ErrorResponse}}
)
async def activate_scene(
scene_id: str,
user: Dict = Depends(get_admin_user)
):
"""
Activate a scene
Args:
scene_id: Scene entity ID (e.g., scene.movie_night)
"""
ha = get_homeassistant_client()
try:
# Verify scene exists
state = await ha.get_state(scene_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "SCENE_NOT_FOUND",
"message": f"Scene {scene_id} not found"}
)
await ha.activate_scene(scene_id)
logger.info(f"Scene {scene_id} activated by {user.get('preferred_username', 'unknown')}")
return SceneActivateResponse(
success=True,
scene_id=scene_id,
message="Scene activated"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to activate scene {scene_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Scripts
# ====================================================================
@router.get(
"/scripts",
response_model=ScriptListResponse,
summary="List available scripts"
)
async def list_scripts():
"""
List all available scripts/sequences in Home Assistant
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
scripts = [
Script(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
)
for s in states
if s["entity_id"].startswith("script.")
]
return ScriptListResponse(scripts=scripts)
except Exception as e:
logger.error(f"Failed to list scripts: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/scripts/{script_id:path}/run",
response_model=ScriptRunResponse,
responses={404: {"model": ErrorResponse}}
)
async def run_script(
script_id: str,
request: Optional[ScriptRunRequest] = None,
user: Dict = Depends(get_admin_user)
):
"""
Execute a script with optional variables
Args:
script_id: Script entity ID (e.g., script.bedtime_routine)
request: Optional variables for the script
"""
ha = get_homeassistant_client()
try:
# Verify script exists
state = await ha.get_state(script_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "SCRIPT_NOT_FOUND",
"message": f"Script {script_id} not found"}
)
variables = request.variables if request else None
await ha.run_script(script_id, variables)
logger.info(f"Script {script_id} executed by {user.get('preferred_username', 'unknown')}")
return ScriptRunResponse(
success=True,
script_id=script_id,
message="Script executed"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to run script {script_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Automations
# ====================================================================
@router.get(
"/automations",
response_model=AutomationListResponse,
summary="List automations"
)
async def list_automations():
"""
List all automations with their enabled/disabled status
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
automations = [
Automation(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"]),
enabled=s.get("state") == "on"
)
for s in states
if s["entity_id"].startswith("automation.")
]
return AutomationListResponse(automations=automations)
except Exception as e:
logger.error(f"Failed to list automations: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/automations/{automation_id:path}/toggle",
response_model=AutomationToggleResponse,
responses={404: {"model": ErrorResponse}}
)
async def toggle_automation(
automation_id: str,
request: AutomationToggleRequest,
user: Dict = Depends(get_admin_user)
):
"""
Enable or disable an automation
Args:
automation_id: Automation entity ID (e.g., automation.motion_lights)
request: Contains enabled boolean
"""
ha = get_homeassistant_client()
try:
# Verify automation exists
state = await ha.get_state(automation_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "AUTOMATION_NOT_FOUND",
"message": f"Automation {automation_id} not found"}
)
if request.enabled:
await ha.enable_automation(automation_id)
else:
await ha.disable_automation(automation_id)
logger.info(f"Automation {automation_id} {'enabled' if request.enabled else 'disabled'} by {user.get('preferred_username', 'unknown')}")
return AutomationToggleResponse(
success=True,
automation_id=automation_id,
enabled=request.enabled,
message=f"Automation {'enabled' if request.enabled else 'disabled'}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to toggle automation {automation_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# History
# ====================================================================
@router.get(
"/history",
response_model=HistoryResponse,
responses={400: {"model": ErrorResponse}}
)
async def get_history(
entity_id: str = Query(..., description="Entity ID to get history for"),
hours: int = Query(24, ge=1, le=168, description="Hours of history (1-168)")
):
"""
Get state history for a device
Query Parameters:
- entity_id: Device entity ID (required)
- hours: Number of hours of history (default 24, max 168/1 week)
"""
ha = get_homeassistant_client()
try:
history_data = await ha.get_history(entity_id, hours)
# Transform HA history format to our format
history_entries = []
if history_data and len(history_data) > 0:
for entry in history_data[0]: # First array is our entity
history_entries.append(HistoryEntry(
state=entry.get("state", "unknown"),
timestamp=entry.get("last_changed", ""),
attributes=entry.get("attributes", {})
))
return HistoryResponse(
entity_id=entity_id,
history=history_entries
)
except Exception as e:
logger.error(f"Failed to get history for {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
return router
# Create controller instance
housekeeping_controller = HousekeepingController()