Files
fulloch/tools/home_assistant.py
T
Liam PettigrewandClaude Opus 4.5 464d049433 init
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 17:06:44 +11:00

416 lines
12 KiB
Python

"""Home Assistant integration for Fulloch voice assistant.
Connects to a Home Assistant instance via REST API for home automation control.
Requires a long-lived access token configured in data/config.yml.
This integration must be explicitly enabled in config.yml to prevent conflicts
with other home automation tools (e.g., Philips Hue lighting).
"""
import requests
import yaml
from typing import Optional
from .tool_registry import tool
# Load configuration
try:
with open("./data/config.yml", "r") as f:
config = yaml.safe_load(f)
HA_CONFIG = config.get('home_assistant', {})
except FileNotFoundError:
HA_CONFIG = {}
# Check if Home Assistant integration is enabled
HA_ENABLED = HA_CONFIG.get('enabled', False)
# Home Assistant connection settings
HA_URL = HA_CONFIG.get('url', 'http://localhost:8123')
HA_TOKEN = HA_CONFIG.get('token', '')
TIMEOUT = HA_CONFIG.get('timeout', 10)
def _noop_decorator(name=None, description=None, aliases=None):
"""No-op decorator when Home Assistant integration is disabled."""
def decorator(func):
return func
return decorator
# Use real tool decorator only when enabled
ha_tool = tool if HA_ENABLED else _noop_decorator
def _get_headers() -> dict:
"""Return authorization headers for Home Assistant API."""
return {
"Authorization": f"Bearer {HA_TOKEN}",
"Content-Type": "application/json",
}
def _call_service(domain: str, service: str, entity_id: str, data: Optional[dict] = None) -> str:
"""Call a Home Assistant service."""
if not HA_TOKEN:
return "Error: Home Assistant token not configured. Add 'token' to home_assistant config."
url = f"{HA_URL}/api/services/{domain}/{service}"
payload = {"entity_id": entity_id}
if data:
payload.update(data)
try:
response = requests.post(url, headers=_get_headers(), json=payload, timeout=TIMEOUT)
response.raise_for_status()
return f"Successfully called {domain}.{service} on {entity_id}"
except requests.exceptions.ConnectionError:
return f"Error: Could not connect to Home Assistant at {HA_URL}"
except requests.exceptions.Timeout:
return f"Error: Home Assistant request timed out"
except requests.exceptions.HTTPError as e:
return f"Error: Home Assistant returned {e.response.status_code}: {e.response.text}"
except Exception as e:
return f"Error: {str(e)}"
def _get_state(entity_id: str) -> Optional[dict]:
"""Get the state of an entity from Home Assistant."""
if not HA_TOKEN:
return None
url = f"{HA_URL}/api/states/{entity_id}"
try:
response = requests.get(url, headers=_get_headers(), timeout=TIMEOUT)
response.raise_for_status()
return response.json()
except Exception:
return None
def _resolve_entity(name: str, domain: str = None) -> str:
"""Resolve a friendly name or alias to an entity_id.
Checks the entity_aliases config mapping first, then assumes
the name is already a valid entity_id.
"""
aliases = HA_CONFIG.get('entity_aliases', {})
# Check if it's a configured alias
if name.lower() in aliases:
return aliases[name.lower()]
# If it looks like an entity_id already, return as-is
if '.' in name:
return name
# Try to construct entity_id from name and domain
if domain:
# Convert "living room lights" -> "light.living_room_lights"
entity_name = name.lower().replace(' ', '_')
return f"{domain}.{entity_name}"
return name
@ha_tool(
name="turn_on",
description="Turn on a device, light, switch, or other Home Assistant entity",
aliases=["ha_turn_on", "switch_on", "turn_on_device"]
)
def turn_on(entity: str, brightness: Optional[int] = None) -> str:
"""Turn on a Home Assistant entity.
Args:
entity: Entity name or ID (e.g., 'living room lights', 'light.living_room')
brightness: Optional brightness percentage (0-100) for lights
"""
entity_id = _resolve_entity(entity, domain="light")
# Determine the domain from entity_id
domain = entity_id.split('.')[0] if '.' in entity_id else 'homeassistant'
data = {}
if brightness is not None and domain == 'light':
# Convert percentage to 0-255 range
data['brightness'] = int((brightness / 100) * 255)
return _call_service(domain, "turn_on", entity_id, data if data else None)
@ha_tool(
name="turn_off",
description="Turn off a device, light, switch, or other Home Assistant entity",
aliases=["ha_turn_off", "switch_off", "turn_off_device"]
)
def turn_off(entity: str) -> str:
"""Turn off a Home Assistant entity.
Args:
entity: Entity name or ID (e.g., 'living room lights', 'light.living_room')
"""
entity_id = _resolve_entity(entity)
domain = entity_id.split('.')[0] if '.' in entity_id else 'homeassistant'
return _call_service(domain, "turn_off", entity_id)
@ha_tool(
name="toggle",
description="Toggle a Home Assistant entity on or off",
aliases=["ha_toggle", "toggle_device"]
)
def toggle(entity: str) -> str:
"""Toggle a Home Assistant entity.
Args:
entity: Entity name or ID (e.g., 'living room lights', 'light.living_room')
"""
entity_id = _resolve_entity(entity)
domain = entity_id.split('.')[0] if '.' in entity_id else 'homeassistant'
return _call_service(domain, "toggle", entity_id)
@ha_tool(
name="ha_set_brightness",
description="Set the brightness of a light in Home Assistant",
aliases=["ha_brightness", "ha_dim_light"]
)
def set_ha_brightness(entity: str, brightness: int) -> str:
"""Set the brightness of a light.
Args:
entity: Light entity name or ID
brightness: Brightness percentage (0-100)
"""
entity_id = _resolve_entity(entity, domain="light")
# Clamp brightness to valid range
brightness = max(0, min(100, brightness))
brightness_255 = int((brightness / 100) * 255)
return _call_service("light", "turn_on", entity_id, {"brightness": brightness_255})
@ha_tool(
name="ha_set_color",
description="Set the color of a light in Home Assistant using color name or RGB",
aliases=["ha_color", "change_light_color"]
)
def set_color(entity: str, color: str) -> str:
"""Set the color of a light.
Args:
entity: Light entity name or ID
color: Color name (red, green, blue, etc.) or RGB as 'r,g,b'
"""
entity_id = _resolve_entity(entity, domain="light")
# Common color name mappings
color_map = {
"red": [255, 0, 0],
"green": [0, 255, 0],
"blue": [0, 0, 255],
"yellow": [255, 255, 0],
"orange": [255, 165, 0],
"purple": [128, 0, 128],
"pink": [255, 192, 203],
"white": [255, 255, 255],
"warm white": [255, 244, 229],
"cool white": [255, 255, 255],
"cyan": [0, 255, 255],
"magenta": [255, 0, 255],
}
color_lower = color.lower().strip()
if color_lower in color_map:
rgb = color_map[color_lower]
elif ',' in color:
# Parse RGB string like "255,128,0"
try:
rgb = [int(c.strip()) for c in color.split(',')]
if len(rgb) != 3:
return "Error: RGB color must have 3 values (e.g., '255,128,0')"
except ValueError:
return f"Error: Invalid RGB color format '{color}'"
else:
return f"Error: Unknown color '{color}'. Use a color name or RGB format."
return _call_service("light", "turn_on", entity_id, {"rgb_color": rgb})
@ha_tool(
name="get_entity_state",
description="Get the current state of a Home Assistant entity",
aliases=["ha_state", "check_state", "is_on"]
)
def get_entity_state(entity: str) -> str:
"""Get the current state of a Home Assistant entity.
Args:
entity: Entity name or ID
"""
entity_id = _resolve_entity(entity)
state = _get_state(entity_id)
if state is None:
return f"Error: Could not get state for {entity_id}"
entity_state = state.get('state', 'unknown')
friendly_name = state.get('attributes', {}).get('friendly_name', entity_id)
# Include relevant attributes
attrs = state.get('attributes', {})
details = [f"{friendly_name} is {entity_state}"]
if 'brightness' in attrs:
brightness_pct = int((attrs['brightness'] / 255) * 100)
details.append(f"brightness: {brightness_pct}%")
if 'temperature' in attrs:
details.append(f"temperature: {attrs['temperature']}°")
if 'current_temperature' in attrs:
details.append(f"current temperature: {attrs['current_temperature']}°")
return ", ".join(details)
@ha_tool(
name="ha_service",
description="Call any Home Assistant service with custom data",
aliases=["call_service", "ha_call"]
)
def call_ha_service(domain: str, service: str, entity: str, data: Optional[str] = None) -> str:
"""Call any Home Assistant service.
Args:
domain: Service domain (e.g., 'light', 'switch', 'climate')
service: Service name (e.g., 'turn_on', 'set_temperature')
entity: Entity ID to target
data: Optional JSON string with additional service data
"""
import json
entity_id = _resolve_entity(entity)
extra_data = None
if data:
try:
extra_data = json.loads(data)
except json.JSONDecodeError:
return f"Error: Invalid JSON data: {data}"
return _call_service(domain, service, entity_id, extra_data)
@ha_tool(
name="ha_set_climate",
description="Set the temperature of a climate/thermostat entity in Home Assistant",
aliases=["ha_climate", "ha_thermostat"]
)
def set_climate(entity: str, temperature: float, hvac_mode: Optional[str] = None) -> str:
"""Set climate/thermostat temperature.
Args:
entity: Climate entity name or ID
temperature: Target temperature
hvac_mode: Optional HVAC mode (heat, cool, auto, off)
"""
entity_id = _resolve_entity(entity, domain="climate")
data = {"temperature": temperature}
if hvac_mode:
data["hvac_mode"] = hvac_mode.lower()
return _call_service("climate", "set_temperature", entity_id, data)
@ha_tool(
name="ha_lock",
description="Lock a lock entity in Home Assistant",
aliases=["lock_door"]
)
def lock(entity: str) -> str:
"""Lock a lock entity.
Args:
entity: Lock entity name or ID
"""
entity_id = _resolve_entity(entity, domain="lock")
return _call_service("lock", "lock", entity_id)
@ha_tool(
name="ha_unlock",
description="Unlock a lock entity in Home Assistant",
aliases=["unlock_door"]
)
def unlock(entity: str) -> str:
"""Unlock a lock entity.
Args:
entity: Lock entity name or ID
"""
entity_id = _resolve_entity(entity, domain="lock")
return _call_service("lock", "unlock", entity_id)
@ha_tool(
name="ha_open_cover",
description="Open a cover/blind/garage in Home Assistant",
aliases=["ha_open", "open_blind", "open_garage"]
)
def open_cover(entity: str) -> str:
"""Open a cover entity (blinds, garage door, etc.).
Args:
entity: Cover entity name or ID
"""
entity_id = _resolve_entity(entity, domain="cover")
return _call_service("cover", "open_cover", entity_id)
@ha_tool(
name="ha_close_cover",
description="Close a cover/blind/garage in Home Assistant",
aliases=["ha_close", "close_blind", "close_garage"]
)
def close_cover(entity: str) -> str:
"""Close a cover entity (blinds, garage door, etc.).
Args:
entity: Cover entity name or ID
"""
entity_id = _resolve_entity(entity, domain="cover")
return _call_service("cover", "close_cover", entity_id)
@ha_tool(
name="ha_run_script",
description="Run a Home Assistant script or automation",
aliases=["ha_script", "run_automation"]
)
def run_script(script_name: str) -> str:
"""Run a Home Assistant script.
Args:
script_name: Script entity ID or name (e.g., 'script.bedtime' or 'bedtime')
"""
entity_id = _resolve_entity(script_name, domain="script")
return _call_service("script", "turn_on", entity_id)
@ha_tool(
name="ha_activate_scene",
description="Activate a Home Assistant scene",
aliases=["ha_scene", "set_scene"]
)
def activate_scene(scene_name: str) -> str:
"""Activate a Home Assistant scene.
Args:
scene_name: Scene entity ID or name (e.g., 'scene.movie_time' or 'movie time')
"""
entity_id = _resolve_entity(scene_name, domain="scene")
return _call_service("scene", "turn_on", entity_id)