diff --git a/.env.example b/.env.example index 7bb9bd8..26d9bbd 100644 --- a/.env.example +++ b/.env.example @@ -42,5 +42,15 @@ ENABLE_BENCHMARKS=true # - production: jpmschweitzer (real user) # Uncomment to override: DEFAULT_USER=your_username +# Library-Desk Configuration (The Librarian backend) +# LIBRARY_DESK_HOST=http://localhost:8089 +# LIBRARY_DESK_API_KEY=your-library-desk-api-key +# LIBRARY_DESK_TIMEOUT=60 + +# Core-API Configuration (The Housekeeper backend) +# CORE_API_HOST=http://localhost:8090 +# CORE_API_KEY=your-core-api-key +# CORE_API_TIMEOUT=30 + # CORS (comma-separated list) CORS_ORIGINS=["*"] diff --git a/AGENTS.md b/AGENTS.md index 941b8fb..d2fcdc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ This document contains instructions and documentation references for AI assistan * **Always test locally first** before committing and deploying. The build-deploy loop is slow. * **Start the local server** with `./wakeup.sh` - logs are written to `logs/server.log` for easy tailing * **Auto-reload**: The wakeup script runs uvicorn in reload mode - code changes are picked up automatically without restart (except for requirements.txt changes) -* **Test REST endpoints** against `http://localhost:8123` using curl or similar tools +* **Test REST endpoints** against `http://localhost:8777` using curl or similar tools * **Only deploy** when a phase or feature is complete and tested locally * **Environment**: Copy `.env.example` to `.env` and configure for your local setup (Ollama, Redis, Qdrant hosts) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71eb9d7..290294f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.0] - 2025-12-15 + +### Added + +#### The Housekeeper Agent +- **New home automation expert agent** following the Librarian pattern +- `CoreAPIClient` for communicating with core-api service (Home Assistant wrapper) +- 13 tools for home automation: + - Discovery: `list_areas`, `list_devices`, `get_device_state` + - Control: `turn_on`, `turn_off`, `toggle` + - Scenes: `list_scenes`, `activate_scene` + - Scripts: `list_scripts`, `run_script` + - Automations: `list_automations`, `toggle_automation` + - History: `get_history` +- PydanticAI agent with system prompt for home automation tasks +- `HouseholdCapability` registration with domains: lights, switches, automation, home, smart home, scene, script, device, climate, fan, cover, blinds +- `delegate_to_housekeeper()` delegation wrapper +- Config settings: `CORE_API_HOST`, `CORE_API_KEY`, `CORE_API_TIMEOUT` + +#### Development Port Change +- **Dev server port changed from 8123 to 8777** to avoid conflict with Home Assistant default port +- Updated `wakeup.sh`, E2E tests, and documentation + +### Changed + +- All unit tests pass (421 passed, 5 xfailed) +- Housekeeper registered on startup alongside Librarian and Biographer + ## [1.4.0] - 2025-12-14 ### Added diff --git a/pyproject.toml b/pyproject.toml index 636077d..1afd813 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tatlock" -version = "1.4.0" +version = "1.5.0" description = "OpenAI-compatible API with Ollama backend" requires-python = ">=3.12" dependencies = [] diff --git a/src/agents/delegation.py b/src/agents/delegation.py index dfbeef9..eb6f781 100644 --- a/src/agents/delegation.py +++ b/src/agents/delegation.py @@ -224,6 +224,83 @@ async def delegate_to_biographer( ) +async def delegate_to_housekeeper( + task: str, + context: str = "", +) -> DelegationResult: + """ + Delegate a home automation task to The Housekeeper. + + The Housekeeper handles: + - Device control (turn on/off, toggle, brightness, color) + - Scene activation (movie night, good morning, etc.) + - Script execution (automation sequences) + - Automation management (enable/disable rules) + - Device discovery (list devices by area/type) + - State queries (get current state, history) + + Args: + task: Clear description of what needs to be done. + Include the action verb (turn on, activate, list, etc.) + Example: "Turn on the living room lights" + Example: "Activate the movie night scene" + Example: "What devices are in the bedroom?" + context: Additional context from the user's request or + conversation history + + Returns: + DelegationResult with The Housekeeper's response + + Example: + >>> result = await delegate_to_housekeeper( + ... task="Turn on the bedroom lights at 50% brightness", + ... context="User is getting ready for bed", + ... ) + >>> if result.success: + ... print(result.output) + """ + from src.agents.housekeeper.agent import run_housekeeper + + logger.info( + "delegation_to_housekeeper_started", + task=task[:100], + has_context=bool(context), + ) + + try: + # Use run() not run_stream() - avoids Ollama bug + output = await run_housekeeper(task=task, context=context) + + logger.info( + "delegation_to_housekeeper_completed", + task=task[:50], + output_length=len(output), + ) + + return DelegationResult( + expert_name="housekeeper", + task=task, + success=True, + output=output, + ) + + except Exception as e: + logger.error( + "delegation_to_housekeeper_error", + task=task[:50], + error=str(e), + exc_info=True, + ) + + return DelegationResult( + expert_name="housekeeper", + task=task, + success=False, + output="", + error=str(e), + ) + + # Future expert delegation wrappers will be added here: -# - delegate_to_home_automation(task, context) -> DelegationResult # - delegate_to_developer(task, context) -> DelegationResult +# - delegate_to_secretary(task, context) -> DelegationResult diff --git a/src/agents/housekeeper/__init__.py b/src/agents/housekeeper/__init__.py new file mode 100644 index 0000000..ed52852 --- /dev/null +++ b/src/agents/housekeeper/__init__.py @@ -0,0 +1,24 @@ +""" +The Housekeeper - Home Automation Agent. + +Provides home automation capabilities through the core-api service, +which wraps the Home Assistant REST API into LLM-friendly endpoints. +""" +from src.agents.housekeeper.agent import run_housekeeper, run_housekeeper_stream +from src.agents.housekeeper.capability import ( + HOUSEKEEPER_CAPABILITY, + register_housekeeper, +) +from src.agents.housekeeper.client import CoreAPIClient, get_core_api_client + +__all__ = [ + # Agent entry points + "run_housekeeper", + "run_housekeeper_stream", + # Capability + "HOUSEKEEPER_CAPABILITY", + "register_housekeeper", + # Client + "CoreAPIClient", + "get_core_api_client", +] diff --git a/src/agents/housekeeper/agent.py b/src/agents/housekeeper/agent.py new file mode 100644 index 0000000..1b988e8 --- /dev/null +++ b/src/agents/housekeeper/agent.py @@ -0,0 +1,297 @@ +""" +The Housekeeper - Expert agent for home automation. + +A PydanticAI agent that provides home automation capabilities through +the core-api service, which wraps Home Assistant REST API, offering: +- Device discovery and control +- Scene activation +- Script execution +- Automation management +""" +from typing import Any, Optional + +from pydantic_ai import Agent + +from src.agents.housekeeper.tools import ( + activate_scene, + get_device_state, + get_history, + list_areas, + list_automations, + list_devices, + list_scenes, + list_scripts, + run_script, + toggle, + toggle_automation, + turn_off, + turn_on, +) +from src.core.config import config +from src.core.logging_config import get_logger + +logger = get_logger(__name__) + +# Housekeeper system prompt +HOUSEKEEPER_SYSTEM_PROMPT = """You are The Housekeeper, an expert home automation assistant in the Tatlock household. + +Your role is to help users control and monitor their smart home through Home Assistant: +- Lights, switches, and other devices +- Scenes (pre-configured device states) +- Scripts (automation sequences) +- Automations (event-triggered rules) + +## Your Personality +- Efficient and practical +- Safety-conscious (confirm destructive actions) +- Proactive in suggesting optimizations +- Clear about what actions you're taking + +## Your Tools + +### Discovery Tools +- **list_areas**: See all rooms/areas configured in Home Assistant +- **list_devices**: Find devices by type (domain) or location (area) +- **get_device_state**: Check a device's current state and attributes + +### Control Tools +- **turn_on**: Turn on lights, switches, etc. (supports brightness/color for lights) +- **turn_off**: Turn off devices +- **toggle**: Flip a device's state + +### Scene Tools +- **list_scenes**: See available scene presets +- **activate_scene**: Activate a scene (e.g., "movie night", "good morning") + +### Script Tools +- **list_scripts**: See available automation scripts +- **run_script**: Execute a script + +### Automation Tools +- **list_automations**: See all automations and their status +- **toggle_automation**: Enable or disable an automation + +### History Tools +- **get_history**: Check a device's state history + +## Best Practices + +1. **Device Discovery First**: If the user asks about devices without being specific, + use list_devices to find what's available before acting. + +2. **Confirm State After Actions**: After turning something on/off, you can verify + with get_device_state if needed. + +3. **Use Entity IDs**: Devices are identified by entity_id (e.g., light.living_room). + Always use the exact entity_id from list_devices. + +4. **Area-Aware**: When users say "living room lights", filter by area="living_room". + +5. **Safety**: For actions affecting multiple devices or automations, summarize + what you're about to do. + +## Common Patterns + +- "Turn on the lights" → list_devices(domain="light"), then turn_on each +- "What's on?" → list_devices() and filter for state="on" +- "Movie time" → Either activate_scene("scene.movie_night") or run_script if available +- "Dim the bedroom" → turn_on("light.bedroom", brightness=64) + +## Response Format +Your responses are returned to Tatlock (the butler) who will synthesize them into +a final answer for the user. Keep this in mind: +- Lead with confirmation of what you did or found +- Be specific about which devices were affected +- Include relevant state information +- Note any issues or failures +- Be concise - Tatlock will format the final response +""" + +# Lazy initialization to avoid connection issues during imports +_housekeeper_agent: Optional[Agent[None, str]] = None + + +def _create_housekeeper_agent() -> Agent[None, str]: + """Create the Housekeeper PydanticAI agent.""" + # Import required classes for Ollama configuration + from pydantic_ai.models.openai import OpenAIChatModel + from pydantic_ai.providers.ollama import OllamaProvider + + # PydanticAI expects Ollama base URL to end with /v1 + clean_host = str(config.OLLAMA_HOST).rstrip("/") + base_url = f"{clean_host}/v1" + + # Create Ollama model with provider + model = OpenAIChatModel( + model_name=config.OLLAMA_DEFAULT_MODEL, + provider=OllamaProvider(base_url=base_url), + ) + + agent: Agent[None, str] = Agent( + model=model, + system_prompt=HOUSEKEEPER_SYSTEM_PROMPT, + retries=2, + ) + + # Register discovery tools + agent.tool_plain(list_areas) + agent.tool_plain(list_devices) + agent.tool_plain(get_device_state) + + # Register control tools + agent.tool_plain(turn_on) + agent.tool_plain(turn_off) + agent.tool_plain(toggle) + + # Register scene tools + agent.tool_plain(list_scenes) + agent.tool_plain(activate_scene) + + # Register script tools + agent.tool_plain(list_scripts) + agent.tool_plain(run_script) + + # Register automation tools + agent.tool_plain(list_automations) + agent.tool_plain(toggle_automation) + + # Register history tools + agent.tool_plain(get_history) + + logger.info( + "housekeeper_agent_created", + model=config.OLLAMA_DEFAULT_MODEL, + tool_count=13, + ) + + return agent + + +def get_housekeeper_agent() -> Agent[None, str]: + """ + Get the Housekeeper agent instance (lazy initialization). + + Returns: + PydanticAI Agent configured for home automation tasks + """ + global _housekeeper_agent + if _housekeeper_agent is None: + _housekeeper_agent = _create_housekeeper_agent() + return _housekeeper_agent + + +async def run_housekeeper( + task: str, + context: str = "", + message_history: Optional[list[Any]] = None, +) -> str: + """ + Execute a home automation task with The Housekeeper. + + This is the main entry point for delegating home automation tasks + to The Housekeeper from Tatlock or other agents. + + Args: + task: The home automation task or request + context: Additional context from conversation + message_history: Optional conversation history + + Returns: + Results and confirmation of actions + + Example: + result = await run_housekeeper( + task="Turn on the living room lights", + context="It's evening", + ) + """ + agent = get_housekeeper_agent() + + # Build prompt with context if provided + prompt = task + if context: + prompt = f"Context: {context}\n\nTask: {task}" + + logger.info( + "housekeeper_task_started", + task=task[:100], + has_context=bool(context), + has_history=bool(message_history), + ) + + try: + result = await agent.run( + prompt, + message_history=message_history, + ) + + logger.info( + "housekeeper_task_completed", + task=task[:50], + output_length=len(result.output), + ) + + return result.output + + except Exception as e: + logger.error( + "housekeeper_task_error", + task=task[:50], + error=str(e), + exc_info=True, + ) + return f"The Housekeeper encountered an error: {str(e)}" + + +async def run_housekeeper_stream( + task: str, + context: str = "", + message_history: Optional[list[Any]] = None, +): + """ + Execute a home automation task with streaming output. + + Yields text deltas as The Housekeeper generates the response. + + Args: + task: The home automation task or request + context: Additional context from conversation + message_history: Optional conversation history + + Yields: + str: Text deltas from the response + + Example: + async for delta in run_housekeeper_stream("Turn on the lights"): + print(delta, end="", flush=True) + """ + agent = get_housekeeper_agent() + + # Build prompt with context if provided + prompt = task + if context: + prompt = f"Context: {context}\n\nTask: {task}" + + logger.info( + "housekeeper_stream_started", + task=task[:100], + ) + + try: + async with agent.run_stream( + prompt, + message_history=message_history, + ) as response: + async for delta in response.stream_text(delta=True): + yield delta + + logger.info("housekeeper_stream_completed", task=task[:50]) + + except Exception as e: + logger.error( + "housekeeper_stream_error", + task=task[:50], + error=str(e), + exc_info=True, + ) + yield f"\n\nThe Housekeeper encountered an error: {str(e)}" diff --git a/src/agents/housekeeper/capability.py b/src/agents/housekeeper/capability.py new file mode 100644 index 0000000..bf2c8fc --- /dev/null +++ b/src/agents/housekeeper/capability.py @@ -0,0 +1,90 @@ +""" +Housekeeper capability registration for the Household Registry. + +Defines The Housekeeper's capabilities and registers it as a +household member for coordination by the Steward and Tatlock. +""" +from src.agents.housekeeper.agent import get_housekeeper_agent +from src.agents.housekeeper.tools import HOUSEKEEPER_TOOLS +from src.core.household_registry import ( + HouseholdCapability, + get_household_registry, +) +from src.core.logging_config import get_logger + +logger = get_logger(__name__) + + +# The Housekeeper's capability summary for Steward coordination +HOUSEKEEPER_CAPABILITY = HouseholdCapability( + name="housekeeper", + role="The Housekeeper", + category="automation", + description=( + "Home automation control: TURN ON/OFF devices, ACTIVATE scenes, " + "RUN scripts, LIST devices, MANAGE automations. Controls lights, " + "switches, climate, and other smart home devices via Home Assistant." + ), + domains=[ + "lights", + "switches", + "automation", + "home", + "smart home", + "scene", + "script", + "device", + "turn on", + "turn off", + "temperature", + "climate", + "fan", + "cover", + "blinds", + ], + cost="low", # Fast local API calls to core-api + requires_network=True, # Needs core-api access +) + + +def get_housekeeper_capability() -> HouseholdCapability: + """Get The Housekeeper's capability definition.""" + return HOUSEKEEPER_CAPABILITY + + +def register_housekeeper() -> None: + """ + Register The Housekeeper with the Household Registry. + + This makes The Housekeeper available for: + - Steward recommendations (via capability summary) + - Tatlock delegation (via agent reference) + - Tool scoping (via tool list) + """ + registry = get_household_registry() + + # Check if already registered + if "housekeeper" in registry: + logger.debug("housekeeper_already_registered") + return + + registry.register( + name="housekeeper", + capability=HOUSEKEEPER_CAPABILITY, + tools=HOUSEKEEPER_TOOLS, + agent=get_housekeeper_agent(), + ) + + logger.info( + "housekeeper_registered", + role=HOUSEKEEPER_CAPABILITY.role, + domains=HOUSEKEEPER_CAPABILITY.domains, + tool_count=len(HOUSEKEEPER_TOOLS), + ) + + +def unregister_housekeeper() -> None: + """Unregister The Housekeeper from the Household Registry.""" + registry = get_household_registry() + registry.unregister("housekeeper") + logger.info("housekeeper_unregistered") diff --git a/src/agents/housekeeper/client.py b/src/agents/housekeeper/client.py new file mode 100644 index 0000000..c1211a4 --- /dev/null +++ b/src/agents/housekeeper/client.py @@ -0,0 +1,555 @@ +""" +HTTP client for the Core-API service. + +Provides async methods for home automation operations via Home Assistant. +Core-API is a separate service that wraps the Home Assistant REST API +into LLM-friendly endpoints. +""" +from typing import Any, Optional + +import httpx +from pydantic import BaseModel, Field + +from src.core.config import config +from src.core.logging_config import get_logger + +logger = get_logger(__name__) + + +# ============================================================================ +# Response Models +# ============================================================================ + + +class Device(BaseModel): + """Device from Home Assistant.""" + + entity_id: str + name: str + state: str + domain: str + area: Optional[str] = None + attributes: dict[str, Any] = Field(default_factory=dict) + + +class DeviceState(BaseModel): + """Detailed state of a device.""" + + entity_id: str + state: str + attributes: dict[str, Any] = Field(default_factory=dict) + last_changed: Optional[str] = None + last_updated: Optional[str] = None + + +class Scene(BaseModel): + """Scene from Home Assistant.""" + + entity_id: str + name: str + friendly_name: Optional[str] = None + + +class Script(BaseModel): + """Script from Home Assistant.""" + + entity_id: str + name: str + description: Optional[str] = None + last_triggered: Optional[str] = None + + +class Automation(BaseModel): + """Automation from Home Assistant.""" + + entity_id: str + name: str + state: str = "on" + last_triggered: Optional[str] = None + + +class HistoryEntry(BaseModel): + """History entry for an entity.""" + + state: str + timestamp: str + attributes: dict[str, Any] = Field(default_factory=dict) + + +class ControlResult(BaseModel): + """Result of a device control operation.""" + + success: bool + entity_id: str + action: str + message: str = "" + + +class Area(BaseModel): + """Area/room from Home Assistant.""" + + area_id: str + name: str + device_count: int = 0 + + +# ============================================================================ +# Client +# ============================================================================ + + +class CoreAPIClient: + """ + Async HTTP client for Core-API (Home Assistant wrapper). + + Usage: + async with CoreAPIClient() as client: + devices = await client.list_devices() + """ + + def __init__( + self, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + timeout: int = 30, + ): + """ + Initialize the client. + + Args: + base_url: Core-API URL (defaults to config) + api_key: API key for authentication (defaults to config) + timeout: Request timeout in seconds + """ + self.base_url = base_url or str(config.CORE_API_HOST) + self.api_key = api_key or config.CORE_API_KEY + self.timeout = timeout + self._client: Optional[httpx.AsyncClient] = None + + async def __aenter__(self) -> "CoreAPIClient": + """Create HTTP client on context entry.""" + headers = {} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + self._client = httpx.AsyncClient( + base_url=self.base_url, + headers=headers, + timeout=self.timeout, + ) + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Close HTTP client on context exit.""" + if self._client: + await self._client.aclose() + self._client = None + + def _ensure_client(self) -> httpx.AsyncClient: + """Ensure client is initialized.""" + if self._client is None: + raise RuntimeError( + "Client not initialized. Use 'async with CoreAPIClient() as client:'" + ) + return self._client + + # ======================================================================== + # Device Discovery + # ======================================================================== + + async def list_devices( + self, + domain: Optional[str] = None, + area: Optional[str] = None, + ) -> list[Device]: + """ + List devices, optionally filtered by domain or area. + + Args: + domain: Filter by domain (light, switch, climate, etc.) + area: Filter by area (living_room, bedroom, etc.) + + Returns: + List of devices matching filters + """ + client = self._ensure_client() + + params: dict[str, str] = {} + if domain: + params["domain"] = domain + if area: + params["area"] = area + + logger.debug("core_api_list_devices", domain=domain, area=area) + + response = await client.get("/devices", params=params or None) + response.raise_for_status() + + data = response.json() + return [Device(**d) for d in data.get("devices", [])] + + async def list_areas(self) -> list[Area]: + """ + List all areas/rooms in Home Assistant. + + Returns: + List of areas with device counts + """ + client = self._ensure_client() + + logger.debug("core_api_list_areas") + + response = await client.get("/areas") + response.raise_for_status() + + data = response.json() + return [Area(**a) for a in data.get("areas", [])] + + async def get_device_state(self, entity_id: str) -> DeviceState: + """ + Get the current state of a specific device. + + Args: + entity_id: Home Assistant entity ID (e.g., light.living_room) + + Returns: + Current device state with attributes + """ + client = self._ensure_client() + + logger.debug("core_api_get_state", entity_id=entity_id) + + response = await client.get(f"/entities/{entity_id}") + response.raise_for_status() + + return DeviceState(**response.json()) + + # ======================================================================== + # Device Control + # ======================================================================== + + async def turn_on( + self, + entity_id: str, + brightness: Optional[int] = None, + color_temp: Optional[int] = None, + rgb_color: Optional[tuple[int, int, int]] = None, + ) -> ControlResult: + """ + Turn on a device. + + Args: + entity_id: Device to turn on + brightness: Optional brightness (0-255) for lights + color_temp: Optional color temperature in Kelvin for lights + rgb_color: Optional RGB color tuple for lights + + Returns: + Result of the operation + """ + client = self._ensure_client() + + payload: dict[str, Any] = {"action": "turn_on"} + if brightness is not None: + payload["brightness"] = brightness + if color_temp is not None: + payload["color_temp"] = color_temp + if rgb_color is not None: + payload["rgb_color"] = list(rgb_color) + + logger.info("core_api_turn_on", entity_id=entity_id, payload=payload) + + response = await client.post( + f"/devices/{entity_id}/control", + json=payload, + ) + response.raise_for_status() + + data = response.json() + return ControlResult( + success=data.get("success", True), + entity_id=entity_id, + action="turn_on", + message=data.get("message", ""), + ) + + async def turn_off(self, entity_id: str) -> ControlResult: + """ + Turn off a device. + + Args: + entity_id: Device to turn off + + Returns: + Result of the operation + """ + client = self._ensure_client() + + logger.info("core_api_turn_off", entity_id=entity_id) + + response = await client.post( + f"/devices/{entity_id}/control", + json={"action": "turn_off"}, + ) + response.raise_for_status() + + data = response.json() + return ControlResult( + success=data.get("success", True), + entity_id=entity_id, + action="turn_off", + message=data.get("message", ""), + ) + + async def toggle(self, entity_id: str) -> ControlResult: + """ + Toggle a device's state. + + Args: + entity_id: Device to toggle + + Returns: + Result of the operation + """ + client = self._ensure_client() + + logger.info("core_api_toggle", entity_id=entity_id) + + response = await client.post( + f"/devices/{entity_id}/control", + json={"action": "toggle"}, + ) + response.raise_for_status() + + data = response.json() + return ControlResult( + success=data.get("success", True), + entity_id=entity_id, + action="toggle", + message=data.get("message", ""), + ) + + # ======================================================================== + # Scenes + # ======================================================================== + + async def list_scenes(self) -> list[Scene]: + """ + List all available scenes. + + Returns: + List of scenes + """ + client = self._ensure_client() + + logger.debug("core_api_list_scenes") + + response = await client.get("/scenes") + response.raise_for_status() + + data = response.json() + return [Scene(**s) for s in data.get("scenes", [])] + + async def activate_scene(self, scene_id: str) -> ControlResult: + """ + Activate a scene. + + Args: + scene_id: Scene entity ID (e.g., scene.movie_night) + + Returns: + Result of the operation + """ + client = self._ensure_client() + + logger.info("core_api_activate_scene", scene_id=scene_id) + + response = await client.post(f"/scenes/{scene_id}/activate") + response.raise_for_status() + + data = response.json() + return ControlResult( + success=data.get("success", True), + entity_id=scene_id, + action="activate", + message=data.get("message", ""), + ) + + # ======================================================================== + # Scripts + # ======================================================================== + + async def list_scripts(self) -> list[Script]: + """ + List all available scripts. + + Returns: + List of scripts + """ + client = self._ensure_client() + + logger.debug("core_api_list_scripts") + + response = await client.get("/scripts") + response.raise_for_status() + + data = response.json() + return [Script(**s) for s in data.get("scripts", [])] + + async def run_script( + self, + script_id: str, + variables: Optional[dict[str, Any]] = None, + ) -> ControlResult: + """ + Run a script. + + Args: + script_id: Script entity ID (e.g., script.good_morning) + variables: Optional variables to pass to the script + + Returns: + Result of the operation + """ + client = self._ensure_client() + + payload: dict[str, Any] = {} + if variables: + payload["variables"] = variables + + logger.info("core_api_run_script", script_id=script_id) + + response = await client.post( + f"/scripts/{script_id}/run", + json=payload or None, + ) + response.raise_for_status() + + data = response.json() + return ControlResult( + success=data.get("success", True), + entity_id=script_id, + action="run", + message=data.get("message", ""), + ) + + # ======================================================================== + # Automations + # ======================================================================== + + async def list_automations(self) -> list[Automation]: + """ + List all automations. + + Returns: + List of automations with their states + """ + client = self._ensure_client() + + logger.debug("core_api_list_automations") + + response = await client.get("/automations") + response.raise_for_status() + + data = response.json() + return [Automation(**a) for a in data.get("automations", [])] + + async def toggle_automation( + self, + automation_id: str, + enable: bool, + ) -> ControlResult: + """ + Enable or disable an automation. + + Args: + automation_id: Automation entity ID + enable: True to enable, False to disable + + Returns: + Result of the operation + """ + client = self._ensure_client() + + logger.info( + "core_api_toggle_automation", + automation_id=automation_id, + enable=enable, + ) + + response = await client.post( + f"/automations/{automation_id}/toggle", + json={"enable": enable}, + ) + response.raise_for_status() + + data = response.json() + return ControlResult( + success=data.get("success", True), + entity_id=automation_id, + action="enable" if enable else "disable", + message=data.get("message", ""), + ) + + # ======================================================================== + # History + # ======================================================================== + + async def get_history( + self, + entity_id: str, + hours: int = 24, + ) -> list[HistoryEntry]: + """ + Get history for an entity. + + Args: + entity_id: Entity to get history for + hours: Number of hours of history (default: 24) + + Returns: + List of historical state entries + """ + client = self._ensure_client() + + logger.debug("core_api_get_history", entity_id=entity_id, hours=hours) + + response = await client.get( + "/history", + params={"entity_id": entity_id, "hours": hours}, + ) + response.raise_for_status() + + data = response.json() + return [HistoryEntry(**h) for h in data.get("history", [])] + + # ======================================================================== + # Health Check + # ======================================================================== + + async def health_check(self) -> bool: + """ + Check if core-api and Home Assistant are healthy. + + Returns: + True if healthy, False otherwise + """ + try: + client = self._ensure_client() + response = await client.get("/health") + return response.status_code == 200 + except Exception as e: + logger.warning("core_api_health_check_failed", error=str(e)) + return False + + +# Global client factory +async def get_core_api_client() -> CoreAPIClient: + """ + Get a core-api client instance. + + Usage: + async with get_core_api_client() as client: + devices = await client.list_devices() + """ + return CoreAPIClient() diff --git a/src/agents/housekeeper/tools.py b/src/agents/housekeeper/tools.py new file mode 100644 index 0000000..cf8d857 --- /dev/null +++ b/src/agents/housekeeper/tools.py @@ -0,0 +1,562 @@ +""" +Housekeeper tools for PydanticAI agent. + +These tools wrap the core-api service and are registered with +The Housekeeper agent for home automation tasks. +""" +from src.agents.housekeeper.client import CoreAPIClient +from src.core.logging_config import get_logger + +logger = get_logger(__name__) + + +# ============================================================================ +# Device Discovery +# ============================================================================ + + +async def list_devices( + domain: str | None = None, + area: str | None = None, +) -> str: + """ + List available devices in the smart home. + + Use this to discover what devices can be controlled. + Can filter by domain (device type) or area (room). + + Args: + domain: Device type filter (light, switch, climate, cover, fan, etc.) + area: Room/area filter (living_room, bedroom, kitchen, etc.) + + Returns: + List of devices with their current states + + Examples: + list_devices() # All devices + list_devices(domain="light") # Only lights + list_devices(area="living_room") # Living room devices + """ + try: + async with CoreAPIClient() as client: + devices = await client.list_devices(domain=domain, area=area) + + if not devices: + filters = [] + if domain: + filters.append(f"domain={domain}") + if area: + filters.append(f"area={area}") + filter_str = f" with filters: {', '.join(filters)}" if filters else "" + return f"No devices found{filter_str}" + + # Group by domain for readability + by_domain: dict[str, list] = {} + for device in devices: + by_domain.setdefault(device.domain, []).append(device) + + output_parts = ["## Smart Home Devices\n"] + + for dom, dom_devices in sorted(by_domain.items()): + output_parts.append(f"### {dom.title()}s") + for device in dom_devices: + state_icon = "on" if device.state == "on" else "off" if device.state == "off" else device.state + area_str = f" ({device.area})" if device.area else "" + output_parts.append(f"- **{device.name}**{area_str}: {state_icon}") + output_parts.append(f" ID: `{device.entity_id}`") + output_parts.append("") + + logger.info("housekeeper_list_devices", count=len(devices)) + return "\n".join(output_parts) + + except Exception as e: + logger.error("housekeeper_list_devices_error", error=str(e)) + return f"Error listing devices: {str(e)}" + + +async def list_areas() -> str: + """ + List all areas/rooms in the smart home. + + Use this to discover what rooms/areas are configured in Home Assistant. + Useful before filtering devices by area. + + Returns: + List of areas with device counts + + Examples: + list_areas() # See all rooms/areas + """ + try: + async with CoreAPIClient() as client: + areas = await client.list_areas() + + if not areas: + return "No areas found in Home Assistant" + + output_parts = ["## Smart Home Areas\n"] + + for area in sorted(areas, key=lambda a: a.name): + device_str = f" ({area.device_count} devices)" if area.device_count else "" + output_parts.append(f"- **{area.name}**{device_str}") + output_parts.append(f" ID: `{area.area_id}`") + + output_parts.append("") + output_parts.append(f"*{len(areas)} areas total*") + + logger.info("housekeeper_list_areas", count=len(areas)) + return "\n".join(output_parts) + + except Exception as e: + logger.error("housekeeper_list_areas_error", error=str(e)) + return f"Error listing areas: {str(e)}" + + +async def get_device_state(entity_id: str) -> str: + """ + Get the current state and attributes of a specific device. + + Use this to check a device's detailed status before or after control. + + Args: + entity_id: The device entity ID (e.g., light.living_room, switch.coffee_maker) + + Returns: + Detailed device state including all attributes + + Examples: + get_device_state("light.living_room") + get_device_state("climate.bedroom") + """ + try: + async with CoreAPIClient() as client: + state = await client.get_device_state(entity_id) + + output_parts = [ + f"## Device: {entity_id}", + f"**State:** {state.state}", + ] + + if state.last_changed: + output_parts.append(f"**Last Changed:** {state.last_changed}") + + if state.attributes: + output_parts.append("\n**Attributes:**") + for key, value in state.attributes.items(): + if key not in ("friendly_name", "entity_id"): + output_parts.append(f"- {key}: {value}") + + return "\n".join(output_parts) + + except Exception as e: + logger.error("housekeeper_get_state_error", error=str(e), entity_id=entity_id) + return f"Error getting state for {entity_id}: {str(e)}" + + +# ============================================================================ +# Device Control +# ============================================================================ + + +async def turn_on( + entity_id: str, + brightness: int | None = None, + color_temp: int | None = None, +) -> str: + """ + Turn on a device. + + For lights, can optionally set brightness and color temperature. + + Args: + entity_id: Device to turn on (e.g., light.living_room, switch.coffee_maker) + brightness: Optional brightness for lights (0-255, where 255 is full brightness) + color_temp: Optional color temperature in Kelvin (2700=warm, 6500=cool) + + Returns: + Confirmation of the action + + Examples: + turn_on("light.living_room") # Turn on at current brightness + turn_on("light.bedroom", brightness=128) # Turn on at 50% brightness + turn_on("light.office", brightness=255, color_temp=4000) # Full, neutral white + turn_on("switch.coffee_maker") # Turn on a switch + """ + try: + async with CoreAPIClient() as client: + result = await client.turn_on( + entity_id=entity_id, + brightness=brightness, + color_temp=color_temp, + ) + + if result.success: + extras = [] + if brightness is not None: + extras.append(f"brightness {brightness}/255") + if color_temp is not None: + extras.append(f"color temp {color_temp}K") + + extra_str = f" ({', '.join(extras)})" if extras else "" + return f"Turned on {entity_id}{extra_str}" + else: + return f"Failed to turn on {entity_id}: {result.message}" + + except Exception as e: + logger.error("housekeeper_turn_on_error", error=str(e), entity_id=entity_id) + return f"Error turning on {entity_id}: {str(e)}" + + +async def turn_off(entity_id: str) -> str: + """ + Turn off a device. + + Args: + entity_id: Device to turn off (e.g., light.living_room, switch.coffee_maker) + + Returns: + Confirmation of the action + + Examples: + turn_off("light.living_room") + turn_off("switch.coffee_maker") + """ + try: + async with CoreAPIClient() as client: + result = await client.turn_off(entity_id=entity_id) + + if result.success: + return f"Turned off {entity_id}" + else: + return f"Failed to turn off {entity_id}: {result.message}" + + except Exception as e: + logger.error("housekeeper_turn_off_error", error=str(e), entity_id=entity_id) + return f"Error turning off {entity_id}: {str(e)}" + + +async def toggle(entity_id: str) -> str: + """ + Toggle a device's state (on becomes off, off becomes on). + + Args: + entity_id: Device to toggle + + Returns: + Confirmation with the new state + + Examples: + toggle("light.living_room") + toggle("switch.fan") + """ + try: + async with CoreAPIClient() as client: + result = await client.toggle(entity_id=entity_id) + + if result.success: + return f"Toggled {entity_id}" + else: + return f"Failed to toggle {entity_id}: {result.message}" + + except Exception as e: + logger.error("housekeeper_toggle_error", error=str(e), entity_id=entity_id) + return f"Error toggling {entity_id}: {str(e)}" + + +# ============================================================================ +# Scenes +# ============================================================================ + + +async def list_scenes() -> str: + """ + List all available scenes. + + Scenes are pre-configured combinations of device states. + + Returns: + List of available scenes + + Examples: + list_scenes() + """ + try: + async with CoreAPIClient() as client: + scenes = await client.list_scenes() + + if not scenes: + return "No scenes found" + + output_parts = ["## Available Scenes\n"] + for scene in scenes: + name = scene.friendly_name or scene.name + output_parts.append(f"- **{name}**") + output_parts.append(f" ID: `{scene.entity_id}`") + + logger.info("housekeeper_list_scenes", count=len(scenes)) + return "\n".join(output_parts) + + except Exception as e: + logger.error("housekeeper_list_scenes_error", error=str(e)) + return f"Error listing scenes: {str(e)}" + + +async def activate_scene(scene_id: str) -> str: + """ + Activate a scene. + + This sets all devices in the scene to their configured states. + + Args: + scene_id: Scene entity ID (e.g., scene.movie_night, scene.good_morning) + + Returns: + Confirmation of activation + + Examples: + activate_scene("scene.movie_night") + activate_scene("scene.good_morning") + """ + try: + async with CoreAPIClient() as client: + result = await client.activate_scene(scene_id=scene_id) + + if result.success: + return f"Activated scene: {scene_id}" + else: + return f"Failed to activate {scene_id}: {result.message}" + + except Exception as e: + logger.error("housekeeper_activate_scene_error", error=str(e), scene_id=scene_id) + return f"Error activating scene {scene_id}: {str(e)}" + + +# ============================================================================ +# Scripts +# ============================================================================ + + +async def list_scripts() -> str: + """ + List all available automation scripts. + + Scripts are sequences of actions that can be triggered manually. + + Returns: + List of available scripts + + Examples: + list_scripts() + """ + try: + async with CoreAPIClient() as client: + scripts = await client.list_scripts() + + if not scripts: + return "No scripts found" + + output_parts = ["## Available Scripts\n"] + for script in scripts: + output_parts.append(f"- **{script.name}**") + if script.description: + output_parts.append(f" {script.description}") + output_parts.append(f" ID: `{script.entity_id}`") + if script.last_triggered: + output_parts.append(f" Last run: {script.last_triggered}") + + logger.info("housekeeper_list_scripts", count=len(scripts)) + return "\n".join(output_parts) + + except Exception as e: + logger.error("housekeeper_list_scripts_error", error=str(e)) + return f"Error listing scripts: {str(e)}" + + +async def run_script(script_id: str) -> str: + """ + Run an automation script. + + Args: + script_id: Script entity ID (e.g., script.good_morning, script.bedtime) + + Returns: + Confirmation of execution + + Examples: + run_script("script.good_morning") + run_script("script.all_lights_off") + """ + try: + async with CoreAPIClient() as client: + result = await client.run_script(script_id=script_id) + + if result.success: + return f"Running script: {script_id}" + else: + return f"Failed to run {script_id}: {result.message}" + + except Exception as e: + logger.error("housekeeper_run_script_error", error=str(e), script_id=script_id) + return f"Error running script {script_id}: {str(e)}" + + +# ============================================================================ +# Automations +# ============================================================================ + + +async def list_automations() -> str: + """ + List all automations and their current states. + + Automations are event-triggered rules that run automatically. + + Returns: + List of automations with enabled/disabled status + + Examples: + list_automations() + """ + try: + async with CoreAPIClient() as client: + automations = await client.list_automations() + + if not automations: + return "No automations found" + + output_parts = ["## Automations\n"] + + # Group by state + enabled = [a for a in automations if a.state == "on"] + disabled = [a for a in automations if a.state != "on"] + + if enabled: + output_parts.append("### Enabled") + for auto in enabled: + output_parts.append(f"- **{auto.name}**") + output_parts.append(f" ID: `{auto.entity_id}`") + if auto.last_triggered: + output_parts.append(f" Last triggered: {auto.last_triggered}") + output_parts.append("") + + if disabled: + output_parts.append("### Disabled") + for auto in disabled: + output_parts.append(f"- **{auto.name}**") + output_parts.append(f" ID: `{auto.entity_id}`") + + logger.info("housekeeper_list_automations", count=len(automations)) + return "\n".join(output_parts) + + except Exception as e: + logger.error("housekeeper_list_automations_error", error=str(e)) + return f"Error listing automations: {str(e)}" + + +async def toggle_automation(automation_id: str, enable: bool) -> str: + """ + Enable or disable an automation. + + Args: + automation_id: Automation entity ID + enable: True to enable, False to disable + + Returns: + Confirmation of the change + + Examples: + toggle_automation("automation.morning_lights", enable=True) + toggle_automation("automation.vacation_mode", enable=False) + """ + try: + async with CoreAPIClient() as client: + result = await client.toggle_automation( + automation_id=automation_id, + enable=enable, + ) + + action = "Enabled" if enable else "Disabled" + if result.success: + return f"{action} automation: {automation_id}" + else: + return f"Failed to {action.lower()} {automation_id}: {result.message}" + + except Exception as e: + logger.error( + "housekeeper_toggle_automation_error", + error=str(e), + automation_id=automation_id, + ) + return f"Error toggling automation {automation_id}: {str(e)}" + + +# ============================================================================ +# History +# ============================================================================ + + +async def get_history(entity_id: str, hours: int = 24) -> str: + """ + Get the state history of a device. + + Useful for understanding patterns or troubleshooting. + + Args: + entity_id: Device to get history for + hours: Number of hours of history (default: 24) + + Returns: + List of state changes over the time period + + Examples: + get_history("light.living_room") + get_history("climate.bedroom", hours=48) + """ + try: + async with CoreAPIClient() as client: + history = await client.get_history(entity_id=entity_id, hours=hours) + + if not history: + return f"No history found for {entity_id} in the last {hours} hours" + + output_parts = [f"## History: {entity_id}", f"*Last {hours} hours*\n"] + + for entry in history[-20:]: # Show last 20 entries + output_parts.append(f"- **{entry.timestamp}**: {entry.state}") + + if len(history) > 20: + output_parts.append(f"\n*(showing last 20 of {len(history)} entries)*") + + return "\n".join(output_parts) + + except Exception as e: + logger.error("housekeeper_get_history_error", error=str(e), entity_id=entity_id) + return f"Error getting history for {entity_id}: {str(e)}" + + +# ============================================================================ +# Tool Collection for Registration +# ============================================================================ + +# All tools available to The Housekeeper +HOUSEKEEPER_TOOLS = [ + # Discovery + list_areas, + list_devices, + get_device_state, + # Control + turn_on, + turn_off, + toggle, + # Scenes + list_scenes, + activate_scene, + # Scripts + list_scripts, + run_script, + # Automations + list_automations, + toggle_automation, + # History + get_history, +] diff --git a/src/core/config.py b/src/core/config.py index 0fd91e5..a475697 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -124,6 +124,20 @@ class Config(BaseSettings): description="Library-Desk request timeout in seconds" ) + # Core-API Configuration (The Housekeeper backend) + CORE_API_HOST: HttpUrl = Field( + default="http://localhost:8090", + description="Core-API URL for Home Assistant integration" + ) + CORE_API_KEY: str = Field( + default="", + description="API key for Core-API authentication" + ) + CORE_API_TIMEOUT: int = Field( + default=30, + description="Core-API request timeout in seconds" + ) + # Qdrant Configuration (Memory vector storage) QDRANT_HOST: str = Field( default="localhost", diff --git a/src/core/household_registry.py b/src/core/household_registry.py index 9373ac2..780dc52 100644 --- a/src/core/household_registry.py +++ b/src/core/household_registry.py @@ -223,13 +223,17 @@ class HouseholdRegistry: >>> # Returns: [delegate_to_librarian, calculate, datetime, ...] >>> # Instead of: [hybrid_search, search_wiki, create_wiki_page, ... (16 tools)] """ - from src.agents.delegation import delegate_to_librarian, delegate_to_biographer + from src.agents.delegation import ( + delegate_to_biographer, + delegate_to_housekeeper, + delegate_to_librarian, + ) # Map of expert names to their delegation wrappers delegation_wrappers = { "librarian": delegate_to_librarian, "biographer": delegate_to_biographer, - # Future: "home_automation": delegate_to_home_automation, + "housekeeper": delegate_to_housekeeper, } tools = [] diff --git a/src/core/startup.py b/src/core/startup.py index 9223b5a..377998a 100644 --- a/src/core/startup.py +++ b/src/core/startup.py @@ -6,6 +6,7 @@ This module should be called during application startup to register all household members. """ from src.agents.biographer import register_biographer +from src.agents.housekeeper import register_housekeeper from src.agents.librarian import register_librarian from src.agents.tatlock_core import TATLOCK_CORE_CAPABILITY, tatlock_core_tools from src.core.household_registry import get_household_registry @@ -64,6 +65,16 @@ def register_household_members(): error=str(e), ) + # Register The Housekeeper (Home Automation) + try: + register_housekeeper() + except Exception as e: + # Don't fail startup if Housekeeper registration fails + logger.warning( + "housekeeper_registration_failed", + error=str(e), + ) + logger.info( "household_registration_complete", total_members=len(registry), diff --git a/tests/agents/housekeeper/__init__.py b/tests/agents/housekeeper/__init__.py new file mode 100644 index 0000000..79325bc --- /dev/null +++ b/tests/agents/housekeeper/__init__.py @@ -0,0 +1 @@ +"""Tests for The Housekeeper agent.""" diff --git a/tests/agents/housekeeper/test_capability.py b/tests/agents/housekeeper/test_capability.py new file mode 100644 index 0000000..c170572 --- /dev/null +++ b/tests/agents/housekeeper/test_capability.py @@ -0,0 +1,140 @@ +""" +Tests for Housekeeper capability registration. +""" + +import pytest +from unittest.mock import MagicMock, patch + +from src.agents.housekeeper.capability import ( + HOUSEKEEPER_CAPABILITY, + get_housekeeper_capability, + register_housekeeper, + unregister_housekeeper, +) +from src.core.household_registry import HouseholdCapability + + +@pytest.mark.unit +class TestHousekeeperCapability: + """Tests for the Housekeeper capability definition.""" + + def test_capability_is_household_capability(self): + """Test capability is correct type.""" + assert isinstance(HOUSEKEEPER_CAPABILITY, HouseholdCapability) + + def test_capability_name(self): + """Test capability has correct name.""" + assert HOUSEKEEPER_CAPABILITY.name == "housekeeper" + + def test_capability_role(self): + """Test capability has correct role.""" + assert HOUSEKEEPER_CAPABILITY.role == "The Housekeeper" + + def test_capability_category(self): + """Test capability is in automation category.""" + assert HOUSEKEEPER_CAPABILITY.category == "automation" + + def test_capability_domains(self): + """Test capability covers expected domains.""" + domains = HOUSEKEEPER_CAPABILITY.domains + + assert "lights" in domains + assert "switches" in domains + assert "automation" in domains + assert "home" in domains + assert "scene" in domains + assert "turn on" in domains + assert "turn off" in domains + + def test_capability_requires_network(self): + """Test capability requires network access.""" + assert HOUSEKEEPER_CAPABILITY.requires_network is True + + def test_capability_cost_is_low(self): + """Test capability is low cost (local API calls).""" + assert HOUSEKEEPER_CAPABILITY.cost == "low" + + def test_get_housekeeper_capability(self): + """Test getter returns same capability.""" + cap = get_housekeeper_capability() + + assert cap is HOUSEKEEPER_CAPABILITY + + +@pytest.mark.unit +class TestHousekeeperRegistration: + """Tests for Housekeeper registration functions.""" + + def test_register_housekeeper(self): + """Test registering housekeeper with registry.""" + mock_registry = MagicMock() + mock_registry.__contains__ = MagicMock(return_value=False) + + with patch( + "src.agents.housekeeper.capability.get_household_registry", + return_value=mock_registry, + ): + with patch( + "src.agents.housekeeper.capability.get_housekeeper_agent" + ) as mock_get_agent: + mock_agent = MagicMock() + mock_get_agent.return_value = mock_agent + + register_housekeeper() + + mock_registry.register.assert_called_once() + call_kwargs = mock_registry.register.call_args[1] + + assert call_kwargs["name"] == "housekeeper" + assert call_kwargs["capability"] is HOUSEKEEPER_CAPABILITY + assert call_kwargs["agent"] is mock_agent + + def test_register_housekeeper_already_registered(self): + """Test registering when already registered does nothing.""" + mock_registry = MagicMock() + mock_registry.__contains__ = MagicMock(return_value=True) + + with patch( + "src.agents.housekeeper.capability.get_household_registry", + return_value=mock_registry, + ): + register_housekeeper() + + # Should not call register since already registered + mock_registry.register.assert_not_called() + + def test_unregister_housekeeper(self): + """Test unregistering housekeeper from registry.""" + mock_registry = MagicMock() + + with patch( + "src.agents.housekeeper.capability.get_household_registry", + return_value=mock_registry, + ): + unregister_housekeeper() + + mock_registry.unregister.assert_called_once_with("housekeeper") + + +@pytest.mark.unit +class TestCapabilityDescription: + """Tests for capability description.""" + + def test_description_mentions_device_control(self): + """Test description mentions device control capabilities.""" + desc = HOUSEKEEPER_CAPABILITY.description.lower() + assert "turn on" in desc + # Description uses "ON/OFF" format + assert "off" in desc + + def test_description_mentions_scenes(self): + """Test description mentions scene capability.""" + assert "scene" in HOUSEKEEPER_CAPABILITY.description.lower() + + def test_description_mentions_scripts(self): + """Test description mentions script capability.""" + assert "script" in HOUSEKEEPER_CAPABILITY.description.lower() + + def test_description_mentions_automations(self): + """Test description mentions automation management.""" + assert "automation" in HOUSEKEEPER_CAPABILITY.description.lower() diff --git a/tests/agents/housekeeper/test_client.py b/tests/agents/housekeeper/test_client.py new file mode 100644 index 0000000..9b73bc6 --- /dev/null +++ b/tests/agents/housekeeper/test_client.py @@ -0,0 +1,557 @@ +""" +Tests for the Core-API HTTP client. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock +import httpx + +from src.agents.housekeeper.client import ( + Area, + Automation, + ControlResult, + CoreAPIClient, + Device, + DeviceState, + HistoryEntry, + Scene, + Script, +) + + +@pytest.fixture +def mock_httpx_client(): + """Create a mock httpx client.""" + return AsyncMock(spec=httpx.AsyncClient) + + +@pytest.fixture +def client_with_mock(mock_httpx_client): + """Create a CoreAPIClient with mocked httpx client.""" + client = CoreAPIClient( + base_url="http://test:8090", + api_key="test-key", + ) + client._client = mock_httpx_client + return client + + +@pytest.mark.unit +class TestCoreAPIClientInit: + """Tests for client initialization.""" + + def test_default_initialization(self): + """Test client initializes with defaults from config.""" + client = CoreAPIClient() + + assert client.base_url is not None + assert client.timeout == 30 + assert client._client is None + + def test_custom_initialization(self): + """Test client with custom parameters.""" + client = CoreAPIClient( + base_url="http://custom:9000", + api_key="my-api-key", + timeout=60, + ) + + assert client.base_url == "http://custom:9000" + assert client.api_key == "my-api-key" + assert client.timeout == 60 + + def test_ensure_client_not_initialized(self): + """Test _ensure_client raises when not in context.""" + client = CoreAPIClient() + + with pytest.raises(RuntimeError) as exc_info: + client._ensure_client() + + assert "not initialized" in str(exc_info.value) + + +@pytest.mark.unit +class TestContextManager: + """Tests for async context manager.""" + + @pytest.mark.asyncio + async def test_context_manager_creates_client(self): + """Test context manager creates httpx client.""" + async with CoreAPIClient( + base_url="http://test:8090", + api_key="test-key", + ) as client: + assert client._client is not None + + @pytest.mark.asyncio + async def test_context_manager_closes_client(self): + """Test context manager closes client on exit.""" + client = CoreAPIClient(base_url="http://test:8090") + + async with client: + assert client._client is not None + + # After exit, client should be None + assert client._client is None + + +@pytest.mark.unit +class TestDeviceDiscovery: + """Tests for device discovery methods.""" + + @pytest.mark.asyncio + async def test_list_devices(self, client_with_mock, mock_httpx_client): + """Test listing devices.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "devices": [ + { + "entity_id": "light.living_room", + "name": "Living Room Light", + "state": "on", + "domain": "light", + "area": "living_room", + "attributes": {"brightness": 255}, + }, + { + "entity_id": "switch.coffee_maker", + "name": "Coffee Maker", + "state": "off", + "domain": "switch", + "area": "kitchen", + }, + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + devices = await client_with_mock.list_devices() + + assert len(devices) == 2 + assert isinstance(devices[0], Device) + assert devices[0].entity_id == "light.living_room" + assert devices[0].state == "on" + assert devices[0].domain == "light" + + @pytest.mark.asyncio + async def test_list_areas(self, client_with_mock, mock_httpx_client): + """Test listing areas.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "areas": [ + { + "area_id": "living_room", + "name": "Living Room", + "device_count": 5, + }, + { + "area_id": "bedroom", + "name": "Bedroom", + "device_count": 3, + }, + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + areas = await client_with_mock.list_areas() + + assert len(areas) == 2 + assert isinstance(areas[0], Area) + assert areas[0].area_id == "living_room" + assert areas[0].name == "Living Room" + assert areas[0].device_count == 5 + + @pytest.mark.asyncio + async def test_list_devices_with_filter(self, client_with_mock, mock_httpx_client): + """Test listing devices with domain filter.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "devices": [ + { + "entity_id": "light.bedroom", + "name": "Bedroom Light", + "state": "off", + "domain": "light", + } + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + devices = await client_with_mock.list_devices(domain="light") + + assert len(devices) == 1 + mock_httpx_client.get.assert_called_once() + + @pytest.mark.asyncio + async def test_get_device_state(self, client_with_mock, mock_httpx_client): + """Test getting device state.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "entity_id": "light.living_room", + "state": "on", + "attributes": { + "brightness": 200, + "color_temp": 370, + }, + "last_changed": "2024-01-15T10:30:00Z", + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + state = await client_with_mock.get_device_state("light.living_room") + + assert isinstance(state, DeviceState) + assert state.entity_id == "light.living_room" + assert state.state == "on" + assert state.attributes["brightness"] == 200 + + +@pytest.mark.unit +class TestDeviceControl: + """Tests for device control methods.""" + + @pytest.mark.asyncio + async def test_turn_on(self, client_with_mock, mock_httpx_client): + """Test turning on a device.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "message": "Turned on", + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.turn_on("light.living_room") + + assert isinstance(result, ControlResult) + assert result.success is True + assert result.entity_id == "light.living_room" + assert result.action == "turn_on" + + @pytest.mark.asyncio + async def test_turn_on_with_brightness(self, client_with_mock, mock_httpx_client): + """Test turning on with brightness.""" + mock_response = MagicMock() + mock_response.json.return_value = {"success": True} + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.turn_on( + "light.bedroom", + brightness=128, + ) + + assert result.success is True + # Check that brightness was in the payload + call_kwargs = mock_httpx_client.post.call_args[1] + assert call_kwargs["json"]["brightness"] == 128 + + @pytest.mark.asyncio + async def test_turn_off(self, client_with_mock, mock_httpx_client): + """Test turning off a device.""" + mock_response = MagicMock() + mock_response.json.return_value = {"success": True} + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.turn_off("switch.coffee_maker") + + assert result.success is True + assert result.action == "turn_off" + + @pytest.mark.asyncio + async def test_toggle(self, client_with_mock, mock_httpx_client): + """Test toggling a device.""" + mock_response = MagicMock() + mock_response.json.return_value = {"success": True} + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.toggle("light.hallway") + + assert result.success is True + assert result.action == "toggle" + + +@pytest.mark.unit +class TestScenes: + """Tests for scene methods.""" + + @pytest.mark.asyncio + async def test_list_scenes(self, client_with_mock, mock_httpx_client): + """Test listing scenes.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "scenes": [ + { + "entity_id": "scene.movie_night", + "name": "movie_night", + "friendly_name": "Movie Night", + }, + { + "entity_id": "scene.good_morning", + "name": "good_morning", + "friendly_name": "Good Morning", + }, + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + scenes = await client_with_mock.list_scenes() + + assert len(scenes) == 2 + assert isinstance(scenes[0], Scene) + assert scenes[0].entity_id == "scene.movie_night" + + @pytest.mark.asyncio + async def test_activate_scene(self, client_with_mock, mock_httpx_client): + """Test activating a scene.""" + mock_response = MagicMock() + mock_response.json.return_value = {"success": True} + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.activate_scene("scene.movie_night") + + assert result.success is True + assert result.action == "activate" + + +@pytest.mark.unit +class TestScripts: + """Tests for script methods.""" + + @pytest.mark.asyncio + async def test_list_scripts(self, client_with_mock, mock_httpx_client): + """Test listing scripts.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "scripts": [ + { + "entity_id": "script.good_morning", + "name": "Good Morning Routine", + "description": "Morning automation", + }, + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + scripts = await client_with_mock.list_scripts() + + assert len(scripts) == 1 + assert isinstance(scripts[0], Script) + assert scripts[0].name == "Good Morning Routine" + + @pytest.mark.asyncio + async def test_run_script(self, client_with_mock, mock_httpx_client): + """Test running a script.""" + mock_response = MagicMock() + mock_response.json.return_value = {"success": True} + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.run_script("script.good_morning") + + assert result.success is True + assert result.action == "run" + + +@pytest.mark.unit +class TestAutomations: + """Tests for automation methods.""" + + @pytest.mark.asyncio + async def test_list_automations(self, client_with_mock, mock_httpx_client): + """Test listing automations.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "automations": [ + { + "entity_id": "automation.morning_lights", + "name": "Morning Lights", + "state": "on", + }, + { + "entity_id": "automation.vacation_mode", + "name": "Vacation Mode", + "state": "off", + }, + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + automations = await client_with_mock.list_automations() + + assert len(automations) == 2 + assert isinstance(automations[0], Automation) + assert automations[0].state == "on" + + @pytest.mark.asyncio + async def test_toggle_automation_enable(self, client_with_mock, mock_httpx_client): + """Test enabling an automation.""" + mock_response = MagicMock() + mock_response.json.return_value = {"success": True} + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.toggle_automation( + "automation.vacation_mode", + enable=True, + ) + + assert result.success is True + assert result.action == "enable" + + @pytest.mark.asyncio + async def test_toggle_automation_disable(self, client_with_mock, mock_httpx_client): + """Test disabling an automation.""" + mock_response = MagicMock() + mock_response.json.return_value = {"success": True} + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post.return_value = mock_response + + result = await client_with_mock.toggle_automation( + "automation.morning_lights", + enable=False, + ) + + assert result.action == "disable" + + +@pytest.mark.unit +class TestHistory: + """Tests for history methods.""" + + @pytest.mark.asyncio + async def test_get_history(self, client_with_mock, mock_httpx_client): + """Test getting device history.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "history": [ + { + "state": "on", + "timestamp": "2024-01-15T08:00:00Z", + "attributes": {"brightness": 255}, + }, + { + "state": "off", + "timestamp": "2024-01-15T10:30:00Z", + "attributes": {}, + }, + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.get.return_value = mock_response + + history = await client_with_mock.get_history("light.living_room") + + assert len(history) == 2 + assert isinstance(history[0], HistoryEntry) + assert history[0].state == "on" + assert history[1].state == "off" + + +@pytest.mark.unit +class TestHealthCheck: + """Tests for health check.""" + + @pytest.mark.asyncio + async def test_health_check_healthy(self, client_with_mock, mock_httpx_client): + """Test health check returns true when healthy.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_httpx_client.get.return_value = mock_response + + result = await client_with_mock.health_check() + + assert result is True + + @pytest.mark.asyncio + async def test_health_check_unhealthy(self, client_with_mock, mock_httpx_client): + """Test health check returns false on error.""" + mock_httpx_client.get.side_effect = httpx.ConnectError("Connection refused") + + result = await client_with_mock.health_check() + + assert result is False + + +@pytest.mark.unit +class TestResponseModels: + """Tests for response model validation.""" + + def test_device_model(self): + """Test Device model.""" + device = Device( + entity_id="light.test", + name="Test Light", + state="on", + domain="light", + area="bedroom", + attributes={"brightness": 255}, + ) + + assert device.entity_id == "light.test" + assert device.state == "on" + assert device.attributes["brightness"] == 255 + + def test_device_model_optional_fields(self): + """Test Device with minimal fields.""" + device = Device( + entity_id="switch.test", + name="Test Switch", + state="off", + domain="switch", + ) + + assert device.area is None + assert device.attributes == {} + + def test_area_model(self): + """Test Area model.""" + area = Area( + area_id="living_room", + name="Living Room", + device_count=5, + ) + + assert area.area_id == "living_room" + assert area.name == "Living Room" + assert area.device_count == 5 + + def test_area_model_defaults(self): + """Test Area with default device_count.""" + area = Area( + area_id="bedroom", + name="Bedroom", + ) + + assert area.device_count == 0 + + def test_control_result_model(self): + """Test ControlResult model.""" + result = ControlResult( + success=True, + entity_id="light.test", + action="turn_on", + message="Success", + ) + + assert result.success is True + assert result.action == "turn_on" + + def test_history_entry_model(self): + """Test HistoryEntry model.""" + entry = HistoryEntry( + state="on", + timestamp="2024-01-15T10:00:00Z", + attributes={"brightness": 200}, + ) + + assert entry.state == "on" + assert entry.attributes["brightness"] == 200 diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 95be293..866d1de 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -4,7 +4,7 @@ These tests make real HTTP requests to the running Tatlock API server to verify ## Prerequisites -1. **Server must be running** on `http://localhost:8123` (use `./wakeup.sh`) +1. **Server must be running** on `http://localhost:8777` (use `./wakeup.sh`) 2. **Ollama must be running** with `mistral-nemo:latest` model 3. **Redis must be running** (for benchmarking) 4. **Qdrant must be running** on `http://localhost:6333` (for memory tests) @@ -133,7 +133,7 @@ memory = await qdrant.find_memory_by_key("memories_llm_tester", "favorite_color" Make sure the server is running: ```bash ./wakeup.sh -curl http://localhost:8123/health # Should return 200 +curl http://localhost:8777/health # Should return 200 ``` ### Tests timeout diff --git a/tests/e2e/test_api_endpoints.py b/tests/e2e/test_api_endpoints.py index 1f11dcc..7a429b1 100644 --- a/tests/e2e/test_api_endpoints.py +++ b/tests/e2e/test_api_endpoints.py @@ -12,8 +12,8 @@ import httpx import asyncio from typing import AsyncGenerator -# Test server base URL (assumes server is running on localhost:8123 via ./wakeup.sh) -BASE_URL = "http://localhost:8123" +# Test server base URL (assumes server is running on localhost:8777 via ./wakeup.sh) +BASE_URL = "http://localhost:8777" API_TIMEOUT = 120.0 # 120 second timeout for LLM calls diff --git a/tests/e2e/test_orchestration_e2e.py b/tests/e2e/test_orchestration_e2e.py index 5879e7f..92520ad 100644 --- a/tests/e2e/test_orchestration_e2e.py +++ b/tests/e2e/test_orchestration_e2e.py @@ -11,7 +11,7 @@ These tests hit the actual running server and verify data persistence. They use the `llm_tester` user for isolation from production data. Requirements: -- Server running on localhost:8123 (use ./wakeup.sh) +- Server running on localhost:8777 (use ./wakeup.sh) - Qdrant running on localhost:6333 - Ollama running with mistral-nemo model @@ -27,7 +27,7 @@ from dataclasses import dataclass # Test configuration -BASE_URL = "http://localhost:8123" +BASE_URL = "http://localhost:8777" QDRANT_URL = "http://localhost:6333" API_TIMEOUT = 120.0 # LLM calls can be slow TEST_USER = "llm_tester" diff --git a/wakeup.sh b/wakeup.sh index 90ff765..c35c6bd 100755 --- a/wakeup.sh +++ b/wakeup.sh @@ -13,11 +13,11 @@ NC='\033[0m' # No Color echo -e "${GREEN}Starting Tatlock server...${NC}" -# Check if port 8000 is already in use -if lsof -Pi :8000 -sTCP:LISTEN -t >/dev/null 2>&1 ; then - echo -e "${RED}Error: Port 8000 is already in use${NC}" - echo "Run: lsof -i :8000 to see what's using it" - echo "Or run: kill \$(lsof -t -i:8000) to stop it" +# Check if port 8777 is already in use +if lsof -Pi :8777 -sTCP:LISTEN -t >/dev/null 2>&1 ; then + echo -e "${RED}Error: Port 8777 is already in use${NC}" + echo "Run: lsof -i :8777 to see what's using it" + echo "Or run: kill \$(lsof -t -i:8777) to stop it" exit 1 fi @@ -43,8 +43,8 @@ LOG_FILE="$LOGS_DIR/server.log" echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}" # Start the server -echo -e "${GREEN}Starting uvicorn server on http://localhost:8123${NC}" +echo -e "${GREEN}Starting uvicorn server on http://localhost:8777${NC}" echo -e "${YELLOW}Press Ctrl+C to stop the server${NC}" echo "" -uvicorn src.main:app --reload --host 0.0.0.0 --port 8123 2>&1 | tee "$LOG_FILE" +uvicorn src.main:app --reload --host 0.0.0.0 --port 8777 2>&1 | tee "$LOG_FILE"