Mechanical only, and separated from the judgment calls that follow so the reviewable changes are not buried in a 98-file whitespace diff. 227 automatic fixes: 60 blank lines carrying whitespace, 60 unsorted import blocks, 34 Optional[X] to X | None, 28 unused imports, 16 deprecated typing imports, 12 datetime.timezone.utc to datetime.UTC, and assorted smaller modernisations. Then `ruff format` over src and tests: 98 files reformatted, 35 already conforming. No file among the unused-import findings defines __all__ or is an __init__.py, so nothing here removes a re-export. `make test`: 658 passed, unchanged from HEAD. Two things observed while verifying, neither addressed here: `pytest tests/` cannot collect — tests/e2e/test_orchestration_e2e.py uses an `e2e` marker that is not registered, and the config is strict about markers. This fails identically at HEAD, so it predates this change; `make test` passes because it ignores tests/e2e, tests/integration and tests/contracts. test_tatlock_tool_call_logging_calculator is flaky. It failed once in a full run with these changes and passed on the next, passes in isolation with them, and fails in isolation at HEAD. It is order- or timing-dependent, not a regression from this commit — established by running the full suite both ways rather than by reasoning about which change could have caused it. Co-Authored-By: Claude <noreply@anthropic.com>
593 lines
19 KiB
Python
593 lines
19 KiB
Python
"""
|
|
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,
|
|
]
|