diff --git a/src/clients/homeassistant_client.py b/src/clients/homeassistant_client.py new file mode 100644 index 0000000..873007f --- /dev/null +++ b/src/clients/homeassistant_client.py @@ -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/ + + 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// + + 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/ + + 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 diff --git a/src/config.py b/src/config.py index 5c3c6b6..59e3884 100644 --- a/src/config.py +++ b/src/config.py @@ -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" diff --git a/src/controllers/housekeeping_controller.py b/src/controllers/housekeeping_controller.py new file mode 100644 index 0000000..4b37421 --- /dev/null +++ b/src/controllers/housekeeping_controller.py @@ -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() diff --git a/src/credentials.example.py b/src/credentials.example.py index a9b6b2e..62b4915 100644 --- a/src/credentials.example.py +++ b/src/credentials.example.py @@ -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 diff --git a/src/main.py b/src/main.py index 9421594..350ca06 100644 --- a/src/main.py +++ b/src/main.py @@ -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/*