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,
|
PORTAINER_URL, PORTAINER_API_KEY,
|
||||||
NPM_URL, NPM_EMAIL, NPM_PASSWORD,
|
NPM_URL, NPM_EMAIL, NPM_PASSWORD,
|
||||||
BRAVE_SEARCH_API_KEY,
|
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:
|
except ImportError:
|
||||||
# Fallback to empty strings if credentials.py doesn't exist
|
# Fallback to empty strings if credentials.py doesn't exist
|
||||||
@@ -39,6 +40,8 @@ except ImportError:
|
|||||||
BRAVE_SEARCH_API_KEY = ""
|
BRAVE_SEARCH_API_KEY = ""
|
||||||
GOOGLE_SEARCH_API_KEY = ""
|
GOOGLE_SEARCH_API_KEY = ""
|
||||||
GOOGLE_SEARCH_ENGINE_ID = ""
|
GOOGLE_SEARCH_ENGINE_ID = ""
|
||||||
|
HOMEASSISTANT_URL = "http://localhost:8123"
|
||||||
|
HOMEASSISTANT_TOKEN = ""
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -122,6 +125,11 @@ class Settings(BaseSettings):
|
|||||||
npm_email: str = NPM_EMAIL
|
npm_email: str = NPM_EMAIL
|
||||||
npm_password: str = NPM_PASSWORD
|
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 Service (AI performance metrics)
|
||||||
core_ai_base_url: str = "http://core-ai:8086"
|
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_URL = "http://localhost:81"
|
||||||
NPM_EMAIL = "admin@example.com"
|
NPM_EMAIL = "admin@example.com"
|
||||||
NPM_PASSWORD = "your_password_here"
|
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.health_controller import health_controller
|
||||||
from src.controllers.static_controller import static_controller
|
from src.controllers.static_controller import static_controller
|
||||||
from src.controllers.ai_controller import router as ai_router
|
from src.controllers.ai_controller import router as ai_router
|
||||||
|
from src.controllers.housekeeping_controller import housekeeping_controller
|
||||||
from src.security import initialize_oidc
|
from src.security import initialize_oidc
|
||||||
|
|
||||||
# Initialize settings
|
# Initialize settings
|
||||||
@@ -100,6 +101,25 @@ app = FastAPI(
|
|||||||
|
|
||||||
Automates infrastructure operations via Portainer and Nginx Proxy Manager APIs.
|
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
|
### Web Scraper
|
||||||
Intelligent web scraping with main content extraction.
|
Intelligent web scraping with main content extraction.
|
||||||
Perfect for extracting articles, documentation, and blog posts for LLM consumption.
|
Perfect for extracting articles, documentation, and blog posts for LLM consumption.
|
||||||
@@ -148,8 +168,9 @@ app.add_middleware(
|
|||||||
|
|
||||||
# Include controller routers
|
# Include controller routers
|
||||||
app.include_router(health_controller.router) # / and /health
|
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(infrastructure_controller.router) # /infrastructure/*
|
||||||
|
app.include_router(housekeeping_controller.router) # /housekeeping/*
|
||||||
app.include_router(static_controller.router) # /static/*
|
app.include_router(static_controller.router) # /static/*
|
||||||
app.include_router(ai_router) # /ai/*
|
app.include_router(ai_router) # /ai/*
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user