Add housekeeping API for Home Assistant integration
Introduces home automation endpoints for the Tatlock Housekeeper agent: - Device discovery and control (turn_on, turn_off, toggle, set_brightness) - Scene activation and script execution - Automation management (enable/disable) - State history queries and area discovery Includes Home Assistant REST client and configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,744 @@
|
||||
"""
|
||||
Housekeeping Controller
|
||||
|
||||
Provides API endpoints for home automation via Home Assistant.
|
||||
Designed for the Tatlock Housekeeper agent and other consumers.
|
||||
"""
|
||||
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)
|
||||
|
||||
# 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()
|
||||
Reference in New Issue
Block a user