diff --git a/.gitignore b/.gitignore index eb10556..c3652b8 100755 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ data/credentials.json data/token.json *.pem *.key +.python_hue # ============================================================================= # Local Data and Models diff --git a/app.py b/app.py index ae84660..a36c45e 100644 --- a/app.py +++ b/app.py @@ -11,9 +11,12 @@ Usage: """ import os +import warnings import yaml from pathlib import Path +warnings.filterwarnings("ignore", message="Setting `pad_token_id` to `eos_token_id`") + # Load configuration with open("./data/config.yml", "r") as f: config = yaml.safe_load(f) diff --git a/data/config.example.yml b/data/config.example.yml index fddfb6b..1abfd6d 100644 --- a/data/config.example.yml +++ b/data/config.example.yml @@ -2,6 +2,13 @@ # Copy this file to config.yml and update with your values. # See README.md for detailed setup instructions for each integration. +# ============================================================================= +# Tool Activation +# ============================================================================= +# Each section below corresponds to an integration/tool. To disable a tool, +# comment out its entire section. Only configured (uncommented) tools are +# loaded on launch. + # ============================================================================= # General Settings # ============================================================================= @@ -23,22 +30,22 @@ general: # ============================================================================= # Register your app at https://developer.spotify.com/dashboard # Required scopes: user-read-playback-state, user-modify-playback-state, user-read-currently-playing -spotify: - client_id: "your_spotify_client_id" - client_secret: "your_spotify_client_secret" - redirect_uri: "http://localhost:8888/callback" - # Device name as shown in Spotify Connect - device_id: "Your Speaker Name" - # Enable Pioneer AVR integration for audio output switching - # Set to false if not using an AVR - use_avr: false +# spotify: +# client_id: "your_spotify_client_id" +# client_secret: "your_spotify_client_secret" +# redirect_uri: "http://localhost:8888/callback" +# # Device name as shown in Spotify Connect +# device_id: "Your Speaker Name" +# # Enable Pioneer AVR integration for audio output switching +# # Set to false if not using an AVR +# use_avr: false # ============================================================================= # Philips Hue Lighting # ============================================================================= # Press the button on your Hue Bridge before first connection -philips: - hue_hub_ip: "192.168.1.100" +# philips: +# hue_hub_ip: "192.168.1.100" # ============================================================================= # Home Assistant Integration @@ -47,29 +54,26 @@ philips: # 1. Go to your HA profile: http://your-ha:8123/profile # 2. Scroll to "Long-Lived Access Tokens" and create a token # -# IMPORTANT: This integration must be explicitly enabled. When enabled, it -# registers generic tool names like "turn_on" and "turn_off" which may conflict -# with other integrations (e.g., Philips Hue lighting). Only enable if you want -# Home Assistant to be your primary home automation controller. -home_assistant: - # Set to true to enable Home Assistant tools (disabled by default) - enabled: false - # URL to your Home Assistant instance - url: "http://192.168.1.50:8123" - # Long-lived access token from your HA profile - token: "your_long_lived_access_token" - # Request timeout in seconds - timeout: 10 - # Map friendly names to entity IDs for easier voice control - # Keys are lowercase, values are full entity IDs - entity_aliases: - living room lights: "light.living_room" - bedroom lights: "light.bedroom" - front door: "lock.front_door" - garage: "cover.garage_door" - thermostat: "climate.main" - movie time: "scene.movie_time" - bedtime: "script.bedtime" +# NOTE: This registers generic tool names like "turn_on" and "turn_off" which +# may conflict with other integrations (e.g., Philips Hue lighting). Comment +# out this entire section if you don't use Home Assistant. +# home_assistant: +# # URL to your Home Assistant instance +# url: "http://192.168.1.50:8123" +# # Long-lived access token from your HA profile +# token: "your_long_lived_access_token" +# # Request timeout in seconds +# timeout: 10 +# # Map friendly names to entity IDs for easier voice control +# # Keys are lowercase, values are full entity IDs +# entity_aliases: +# living room lights: "light.living_room" +# bedroom lights: "light.bedroom" +# front door: "lock.front_door" +# garage: "cover.garage_door" +# thermostat: "climate.main" +# movie time: "scene.movie_time" +# bedtime: "script.bedtime" # ============================================================================= # Weather - Bureau of Meteorology (Australia) @@ -88,20 +92,20 @@ bom: # 1. Create credentials at https://console.cloud.google.com/ # 2. Enable Google Calendar API # 3. Download OAuth 2.0 credentials JSON file -google: - # Path to OAuth client credentials file - cred_file: "./data/credentials.json" - # Path to store OAuth token (auto-generated on first auth) - token_file: "./data/token.json" +# google: +# # Path to OAuth client credentials file +# cred_file: "./data/credentials.json" +# # Path to store OAuth token (auto-generated on first auth) +# token_file: "./data/token.json" # ============================================================================= # LG ThinQ (Smart Appliances) # ============================================================================= # Get credentials from LG ThinQ developer portal -thinq: - access_token: "your_thinq_access_token" - country_code: "AU" - client_id: "your_thinq_client_id" +# thinq: +# access_token: "your_thinq_access_token" +# country_code: "AU" +# client_id: "your_thinq_client_id" # ============================================================================= # Web Search - SearXNG @@ -115,26 +119,26 @@ search: # ============================================================================= # Find TV IP in TV settings: Network > Wi-Fi Connection > Advanced # Find MAC address in TV settings: Network > Wi-Fi Connection > Advanced -webos: - ip_address: "192.168.1.101" - mac_address: "AA:BB:CC:DD:EE:FF" +# webos: +# ip_address: "192.168.1.101" +# mac_address: "AA:BB:CC:DD:EE:FF" # ============================================================================= # Pioneer/Onkyo AVR (Audio Receiver) # ============================================================================= # Uses eISCP protocol over network # Default port is 60128 for most Pioneer/Onkyo receivers -pioneer: - avr_host: "192.168.1.102" - avr_port: 60128 +# pioneer: +# avr_host: "192.168.1.102" +# avr_port: 60128 # ============================================================================= # Airtouch HVAC Control # ============================================================================= # Map zone names to zone IDs (0-indexed) # Zone IDs correspond to the order in your Airtouch system -airtouch: - living room: 0 - bedroom: 1 - office: 2 - upstairs: 3 +# airtouch: +# living room: 0 +# bedroom: 1 +# office: 2 +# upstairs: 3 diff --git a/tools/__init__.py b/tools/__init__.py index fc6d2bb..e1dcf42 100755 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -2,36 +2,58 @@ Tools package for the voice assistant. This package contains all the tools that can be used by the voice assistant. -All tools are automatically registered with the tool registry when this -package is imported. +Tools are conditionally loaded based on which integrations are configured +in data/config.yml. Commenting out or removing a config section disables +that tool entirely. """ -# Import all tools to ensure they are registered with the tool registry -from . import spotify -from . import lighting -from . import weather_time -from . import google_calendar -from . import airtouch -from . import thinq -from . import webos -from . import search_web -from . import pioneer_avr -from . import home_assistant +import yaml +import logging +import importlib + +logger = logging.getLogger(__name__) + +# Load configuration to determine which tools to activate +try: + with open("./data/config.yml", "r") as f: + _config = yaml.safe_load(f) or {} +except FileNotFoundError: + _config = {} + +# Mapping of tool module names to their required config key. +# A tool is only loaded if its config key is present in config.yml. +_TOOL_CONFIG_MAP = { + 'spotify': 'spotify', + 'lighting': 'philips', + 'google_calendar': 'google', + 'airtouch': 'airtouch', + 'thinq': 'thinq', + 'webos': 'webos', + 'search_web': 'search', + 'pioneer_avr': 'pioneer', + 'home_assistant': 'home_assistant', +} + +# Always load these tools (no config dependency) +_ALWAYS_LOAD = ['weather_time'] + +for _module_name in _ALWAYS_LOAD: + try: + importlib.import_module(f'.{_module_name}', package=__name__) + except Exception as e: + logger.error(f"Failed to load tool {_module_name}: {e}") + +for _module_name, _config_key in _TOOL_CONFIG_MAP.items(): + if _config_key in _config: + try: + importlib.import_module(f'.{_module_name}', package=__name__) + logger.info(f"Loaded tool: {_module_name}") + except Exception as e: + logger.error(f"Failed to load tool {_module_name}: {e}") + else: + logger.info(f"Skipping tool {_module_name} ('{_config_key}' not in config)") # Import the tool registry from .tool_registry import tool_registry, tool -__all__ = [ - 'tool_registry', - 'tool', - 'spotify', - 'lighting', - 'weather_time', - 'google_calendar', - 'airtouch', - 'thinq', - 'webos', - 'search_web', - 'pioneer_avr', - 'home_assistant' -] \ No newline at end of file +__all__ = ['tool_registry', 'tool'] diff --git a/tools/home_assistant.py b/tools/home_assistant.py index da44342..993469a 100644 --- a/tools/home_assistant.py +++ b/tools/home_assistant.py @@ -3,8 +3,9 @@ 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). +This module is only loaded when 'home_assistant' is present in config.yml. +Note: This registers generic tool names like "turn_on" and "turn_off" which +may conflict with other integrations (e.g., Philips Hue lighting). """ import requests @@ -14,15 +15,9 @@ 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) +with open("./data/config.yml", "r") as f: + config = yaml.safe_load(f) +HA_CONFIG = config.get('home_assistant', {}) # Home Assistant connection settings HA_URL = HA_CONFIG.get('url', 'http://localhost:8123') @@ -30,17 +25,6 @@ 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 { @@ -112,7 +96,7 @@ def _resolve_entity(name: str, domain: str = None) -> str: return name -@ha_tool( +@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"] @@ -137,7 +121,7 @@ def turn_on(entity: str, brightness: Optional[int] = None) -> str: return _call_service(domain, "turn_on", entity_id, data if data else None) -@ha_tool( +@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"] @@ -154,7 +138,7 @@ def turn_off(entity: str) -> str: return _call_service(domain, "turn_off", entity_id) -@ha_tool( +@tool( name="toggle", description="Toggle a Home Assistant entity on or off", aliases=["ha_toggle", "toggle_device"] @@ -171,7 +155,7 @@ def toggle(entity: str) -> str: return _call_service(domain, "toggle", entity_id) -@ha_tool( +@tool( name="ha_set_brightness", description="Set the brightness of a light in Home Assistant", aliases=["ha_brightness", "ha_dim_light"] @@ -192,7 +176,7 @@ def set_ha_brightness(entity: str, brightness: int) -> str: return _call_service("light", "turn_on", entity_id, {"brightness": brightness_255}) -@ha_tool( +@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"] @@ -240,7 +224,7 @@ def set_color(entity: str, color: str) -> str: return _call_service("light", "turn_on", entity_id, {"rgb_color": rgb}) -@ha_tool( +@tool( name="get_entity_state", description="Get the current state of a Home Assistant entity", aliases=["ha_state", "check_state", "is_on"] @@ -275,7 +259,7 @@ def get_entity_state(entity: str) -> str: return ", ".join(details) -@ha_tool( +@tool( name="ha_service", description="Call any Home Assistant service with custom data", aliases=["call_service", "ha_call"] @@ -303,7 +287,7 @@ def call_ha_service(domain: str, service: str, entity: str, data: Optional[str] return _call_service(domain, service, entity_id, extra_data) -@ha_tool( +@tool( name="ha_set_climate", description="Set the temperature of a climate/thermostat entity in Home Assistant", aliases=["ha_climate", "ha_thermostat"] @@ -325,7 +309,7 @@ def set_climate(entity: str, temperature: float, hvac_mode: Optional[str] = None return _call_service("climate", "set_temperature", entity_id, data) -@ha_tool( +@tool( name="ha_lock", description="Lock a lock entity in Home Assistant", aliases=["lock_door"] @@ -340,7 +324,7 @@ def lock(entity: str) -> str: return _call_service("lock", "lock", entity_id) -@ha_tool( +@tool( name="ha_unlock", description="Unlock a lock entity in Home Assistant", aliases=["unlock_door"] @@ -355,7 +339,7 @@ def unlock(entity: str) -> str: return _call_service("lock", "unlock", entity_id) -@ha_tool( +@tool( name="ha_open_cover", description="Open a cover/blind/garage in Home Assistant", aliases=["ha_open", "open_blind", "open_garage"] @@ -370,7 +354,7 @@ def open_cover(entity: str) -> str: return _call_service("cover", "open_cover", entity_id) -@ha_tool( +@tool( name="ha_close_cover", description="Close a cover/blind/garage in Home Assistant", aliases=["ha_close", "close_blind", "close_garage"] @@ -385,7 +369,7 @@ def close_cover(entity: str) -> str: return _call_service("cover", "close_cover", entity_id) -@ha_tool( +@tool( name="ha_run_script", description="Run a Home Assistant script or automation", aliases=["ha_script", "run_automation"] @@ -400,7 +384,7 @@ def run_script(script_name: str) -> str: return _call_service("script", "turn_on", entity_id) -@ha_tool( +@tool( name="ha_activate_scene", description="Activate a Home Assistant scene", aliases=["ha_scene", "set_scene"] diff --git a/tools/lighting.py b/tools/lighting.py index 4d0fa59..cc28d93 100755 --- a/tools/lighting.py +++ b/tools/lighting.py @@ -10,7 +10,7 @@ from phue import Bridge from .tool_registry import tool, tool_registry -b = Bridge(config['philips']['hue_hub_ip']) +b = Bridge(config['philips']['hue_hub_ip'], config_file_path="./data/.python_hue") @tool( diff --git a/tools/weather_time.py b/tools/weather_time.py index 83ed672..52217f0 100755 --- a/tools/weather_time.py +++ b/tools/weather_time.py @@ -3,8 +3,11 @@ Weather and time information tool using the centralized tool registry. """ import yaml -with open("./data/config.yml", "r") as f: - config = yaml.safe_load(f) +try: + with open("./data/config.yml", "r") as f: + config = yaml.safe_load(f) or {} +except FileNotFoundError: + config = {} from datetime import datetime import re @@ -75,14 +78,10 @@ def summarize_today_tomorrow(forecast_data, location): def load_weather_config(): - try: - return config['bom'] - except Exception as e: - # Fallback to default values if config file is not available - return { - "host": "ftp.bom.gov.au", - "path": "/anon/gen/fwo/IDN11060.xml" - } + return config.get('bom', { + "host": "ftp.bom.gov.au", + "path": "/anon/gen/fwo/IDN11060.xml" + }) @tool( @@ -90,18 +89,20 @@ def load_weather_config(): description="Get weather forecast for a location", aliases=["weather", "forecast", "get_weather"] ) -def get_weather_forecast(location: str = config['bom']['default']) -> str: +def get_weather_forecast(location: str = None) -> str: """ Get weather forecast for the specified location. - + Args: location: The location for weather forecast - + Returns: Weather forecast summary """ # Load FTP configuration ftp_config = load_weather_config() + if location is None: + location = ftp_config.get('default', 'Sydney') # Connect to FTP and retrieve file into memory ftp = FTP(ftp_config['host'])