""" 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") # Sort devices: room groups first (using Home Assistant's is_hue_group attribute) def is_room_group(d: object) -> bool: """Check if device is a room group based on HA attributes.""" attrs = getattr(d, "attributes", {}) # Check for Hue room groups if attrs.get("is_hue_group") and attrs.get("hue_type") == "room": return True # Check for other group indicators (icon or entity_id list) if "entity_id" in attrs and isinstance(attrs["entity_id"], list): return True return False sorted_devices = sorted( dom_devices, key=lambda d: (not is_room_group(d), d.entity_id) ) for device in sorted_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 "" # Mark room groups clearly using actual HA data group_marker = " [ROOM GROUP]" if is_room_group(device) else "" output_parts.append( f"- **{device.name}**{area_str}{group_marker}: {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. Use the entity_id parameter with the EXACT value from list_devices. For lights, can optionally set brightness and color temperature. Args: entity_id: The EXACT entity ID from list_devices including domain prefix. 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(entity_id="light.living_room") turn_on(entity_id="light.bedroom", brightness=128) turn_on(entity_id="switch.coffee_maker") """ 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. Use the entity_id parameter with the EXACT value from list_devices. Args: entity_id: The EXACT entity ID from list_devices including domain prefix. Returns: Confirmation of the action Examples: turn_off(entity_id="light.living_room") turn_off(entity_id="switch.coffee_maker") turn_off(entity_id="light.kitchen") """ 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). Use the entity_id parameter with the EXACT value from list_devices. Args: entity_id: The EXACT entity ID from list_devices including domain prefix. Returns: Confirmation with the new state Examples: toggle(entity_id="light.living_room") toggle(entity_id="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, ]