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,409 @@
|
||||
"""
|
||||
Home Assistant REST API Client
|
||||
|
||||
Provides interface to Home Assistant REST API for home automation control.
|
||||
Uses long-lived access token authentication.
|
||||
API Reference: https://developers.home-assistant.io/docs/api/rest/
|
||||
"""
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class HomeAssistantClient:
|
||||
"""
|
||||
HTTP client for Home Assistant REST API
|
||||
|
||||
Uses long-lived access token authentication via Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
timeout: int = 30
|
||||
):
|
||||
"""
|
||||
Initialize Home Assistant client
|
||||
|
||||
Args:
|
||||
base_url: Home Assistant base URL (default from settings)
|
||||
token: Long-lived access token (default from settings)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = (base_url or settings.homeassistant_url).rstrip("/")
|
||||
self.token = token or settings.homeassistant_token
|
||||
self.timeout = timeout
|
||||
|
||||
if not self.token:
|
||||
logger.warning("Home Assistant token not configured")
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Get request headers with Bearer token authentication"""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# ========================================================================
|
||||
# Health & Discovery
|
||||
# ========================================================================
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Check Home Assistant API connectivity and get version info
|
||||
|
||||
HA Endpoint: GET /api/
|
||||
|
||||
Returns:
|
||||
Dict with connected status, platform name, and version
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"connected": True,
|
||||
"platform": "home_assistant",
|
||||
"version": data.get("version", "unknown")
|
||||
}
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"connected": False,
|
||||
"platform": "home_assistant",
|
||||
"error": f"HTTP {response.status_code}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Home Assistant health check failed: {e}")
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"connected": False,
|
||||
"platform": "home_assistant",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def get_states(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all entity states
|
||||
|
||||
HA Endpoint: GET /api/states
|
||||
|
||||
Returns:
|
||||
List of all entity states
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/states",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_state(self, entity_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get state of a specific entity
|
||||
|
||||
HA Endpoint: GET /api/states/<entity_id>
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID (e.g., "light.living_room")
|
||||
|
||||
Returns:
|
||||
Entity state dict or None if not found
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/states/{entity_id}",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get Home Assistant configuration (includes areas)
|
||||
|
||||
HA Endpoint: GET /api/config
|
||||
|
||||
Returns:
|
||||
Configuration dict including components, location, etc.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/config",
|
||||
headers=self._get_headers()
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========================================================================
|
||||
# Device Control
|
||||
# ========================================================================
|
||||
|
||||
async def call_service(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
entity_id: Optional[str] = None,
|
||||
service_data: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Call a Home Assistant service
|
||||
|
||||
HA Endpoint: POST /api/services/<domain>/<service>
|
||||
|
||||
Args:
|
||||
domain: Service domain (e.g., "light", "switch", "scene")
|
||||
service: Service name (e.g., "turn_on", "turn_off", "toggle")
|
||||
entity_id: Target entity ID (optional for some services)
|
||||
service_data: Additional service data/attributes
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
payload = service_data.copy() if service_data else {}
|
||||
if entity_id:
|
||||
payload["entity_id"] = entity_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/services/{domain}/{service}",
|
||||
headers=self._get_headers(),
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def turn_on(
|
||||
self,
|
||||
entity_id: str,
|
||||
**attributes
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Turn on an entity with optional attributes
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID (e.g., "light.living_room")
|
||||
**attributes: Additional attributes (brightness, color_temp, etc.)
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return await self.call_service(
|
||||
domain=domain,
|
||||
service="turn_on",
|
||||
entity_id=entity_id,
|
||||
service_data=attributes if attributes else None
|
||||
)
|
||||
|
||||
async def turn_off(self, entity_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Turn off an entity
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return await self.call_service(
|
||||
domain=domain,
|
||||
service="turn_off",
|
||||
entity_id=entity_id
|
||||
)
|
||||
|
||||
async def toggle(self, entity_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Toggle an entity
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return await self.call_service(
|
||||
domain=domain,
|
||||
service="toggle",
|
||||
entity_id=entity_id
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Scenes
|
||||
# ========================================================================
|
||||
|
||||
async def activate_scene(self, scene_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Activate a scene
|
||||
|
||||
Args:
|
||||
scene_id: Scene entity ID (e.g., "scene.movie_night")
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
return await self.call_service(
|
||||
domain="scene",
|
||||
service="turn_on",
|
||||
entity_id=scene_id
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Scripts
|
||||
# ========================================================================
|
||||
|
||||
async def run_script(
|
||||
self,
|
||||
script_id: str,
|
||||
variables: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute a script with optional variables
|
||||
|
||||
Args:
|
||||
script_id: Script entity ID (e.g., "script.bedtime_routine")
|
||||
variables: Script variables
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
service_data = {"variables": variables} if variables else None
|
||||
return await self.call_service(
|
||||
domain="script",
|
||||
service="turn_on",
|
||||
entity_id=script_id,
|
||||
service_data=service_data
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Automations
|
||||
# ========================================================================
|
||||
|
||||
async def enable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Enable an automation
|
||||
|
||||
Args:
|
||||
automation_id: Automation entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
return await self.call_service(
|
||||
domain="automation",
|
||||
service="turn_on",
|
||||
entity_id=automation_id
|
||||
)
|
||||
|
||||
async def disable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Disable an automation
|
||||
|
||||
Args:
|
||||
automation_id: Automation entity ID
|
||||
|
||||
Returns:
|
||||
List of changed states
|
||||
"""
|
||||
return await self.call_service(
|
||||
domain="automation",
|
||||
service="turn_off",
|
||||
entity_id=automation_id
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# History
|
||||
# ========================================================================
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
entity_id: str,
|
||||
hours: int = 24
|
||||
) -> List[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get state history for an entity
|
||||
|
||||
HA Endpoint: GET /api/history/period/<timestamp>
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID to get history for
|
||||
hours: Number of hours of history (default 24)
|
||||
|
||||
Returns:
|
||||
List of state history entries
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
timestamp = start_time.isoformat()
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/history/period/{timestamp}",
|
||||
headers=self._get_headers(),
|
||||
params={
|
||||
"filter_entity_id": entity_id,
|
||||
"minimal_response": "true"
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========================================================================
|
||||
# Areas (via template API)
|
||||
# ========================================================================
|
||||
|
||||
async def get_areas(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get all areas/rooms
|
||||
|
||||
Note: The REST API doesn't have a direct areas endpoint.
|
||||
This uses the template API to render area data.
|
||||
|
||||
HA Endpoint: POST /api/template
|
||||
|
||||
Returns:
|
||||
List of area dicts with id and name
|
||||
"""
|
||||
template = """
|
||||
{% set areas_list = [] %}
|
||||
{% for area in areas() %}
|
||||
{% set areas_list = areas_list + [{"id": area, "name": area_name(area)}] %}
|
||||
{% endfor %}
|
||||
{{ areas_list | tojson }}
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/template",
|
||||
headers=self._get_headers(),
|
||||
json={"template": template}
|
||||
)
|
||||
response.raise_for_status()
|
||||
# Response is rendered template as string
|
||||
return json.loads(response.text)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_homeassistant_client: Optional[HomeAssistantClient] = None
|
||||
|
||||
|
||||
def get_homeassistant_client() -> HomeAssistantClient:
|
||||
"""Get singleton Home Assistant client instance"""
|
||||
global _homeassistant_client
|
||||
if _homeassistant_client is None:
|
||||
_homeassistant_client = HomeAssistantClient()
|
||||
return _homeassistant_client
|
||||
+9
-1
@@ -26,7 +26,8 @@ try:
|
||||
PORTAINER_URL, PORTAINER_API_KEY,
|
||||
NPM_URL, NPM_EMAIL, NPM_PASSWORD,
|
||||
BRAVE_SEARCH_API_KEY,
|
||||
GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
|
||||
GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID,
|
||||
HOMEASSISTANT_URL, HOMEASSISTANT_TOKEN
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback to empty strings if credentials.py doesn't exist
|
||||
@@ -39,6 +40,8 @@ except ImportError:
|
||||
BRAVE_SEARCH_API_KEY = ""
|
||||
GOOGLE_SEARCH_API_KEY = ""
|
||||
GOOGLE_SEARCH_ENGINE_ID = ""
|
||||
HOMEASSISTANT_URL = "http://localhost:8123"
|
||||
HOMEASSISTANT_TOKEN = ""
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -122,6 +125,11 @@ class Settings(BaseSettings):
|
||||
npm_email: str = NPM_EMAIL
|
||||
npm_password: str = NPM_PASSWORD
|
||||
|
||||
# Home Assistant Configuration
|
||||
homeassistant_url: str = HOMEASSISTANT_URL
|
||||
homeassistant_token: str = HOMEASSISTANT_TOKEN
|
||||
homeassistant_timeout: int = 30
|
||||
|
||||
# Core-AI Service (AI performance metrics)
|
||||
core_ai_base_url: str = "http://core-ai:8086"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -17,3 +17,7 @@ PORTAINER_API_KEY = "ptr_your_api_token_here" # Create in Portainer UI: User me
|
||||
NPM_URL = "http://localhost:81"
|
||||
NPM_EMAIL = "admin@example.com"
|
||||
NPM_PASSWORD = "your_password_here"
|
||||
|
||||
# Home Assistant Configuration
|
||||
HOMEASSISTANT_URL = "http://192.168.86.149:8123" # Or http://home-assistant:8123 in Docker
|
||||
HOMEASSISTANT_TOKEN = "your_long_lived_access_token_here" # Create in HA: Profile → Long-Lived Access Tokens
|
||||
|
||||
+22
-1
@@ -14,6 +14,7 @@ from src.controllers.tools_controller import tools_controller
|
||||
from src.controllers.health_controller import health_controller
|
||||
from src.controllers.static_controller import static_controller
|
||||
from src.controllers.ai_controller import router as ai_router
|
||||
from src.controllers.housekeeping_controller import housekeeping_controller
|
||||
from src.security import initialize_oidc
|
||||
|
||||
# Initialize settings
|
||||
@@ -100,6 +101,25 @@ app = FastAPI(
|
||||
|
||||
Automates infrastructure operations via Portainer and Nginx Proxy Manager APIs.
|
||||
|
||||
### Home Automation (Housekeeping)
|
||||
**Read Endpoints:**
|
||||
- `GET /housekeeping/health` - Check Home Assistant connectivity
|
||||
- `GET /housekeeping/devices` - List devices (filter by domain, area)
|
||||
- `GET /housekeeping/devices/{entity_id}` - Get device details
|
||||
- `GET /housekeeping/areas` - List areas/rooms
|
||||
- `GET /housekeeping/scenes` - List scenes
|
||||
- `GET /housekeeping/scripts` - List scripts
|
||||
- `GET /housekeeping/automations` - List automations
|
||||
- `GET /housekeeping/history` - Get state history
|
||||
|
||||
**Write Endpoints (Admin Only):**
|
||||
- `POST /housekeeping/devices/{entity_id}/control` - Control device
|
||||
- `POST /housekeeping/scenes/{scene_id}/activate` - Activate scene
|
||||
- `POST /housekeeping/scripts/{script_id}/run` - Run script
|
||||
- `POST /housekeeping/automations/{automation_id}/toggle` - Enable/disable automation
|
||||
|
||||
Abstracts Home Assistant for the Tatlock Housekeeper agent and other consumers.
|
||||
|
||||
### Web Scraper
|
||||
Intelligent web scraping with main content extraction.
|
||||
Perfect for extracting articles, documentation, and blog posts for LLM consumption.
|
||||
@@ -148,8 +168,9 @@ app.add_middleware(
|
||||
|
||||
# Include controller routers
|
||||
app.include_router(health_controller.router) # / and /health
|
||||
app.include_router(tools_controller.router) # /web-scraper/scrape
|
||||
app.include_router(tools_controller.router) # /tools/*
|
||||
app.include_router(infrastructure_controller.router) # /infrastructure/*
|
||||
app.include_router(housekeeping_controller.router) # /housekeeping/*
|
||||
app.include_router(static_controller.router) # /static/*
|
||||
app.include_router(ai_router) # /ai/*
|
||||
|
||||
|
||||
Reference in New Issue
Block a user