diff --git a/.gitignore b/.gitignore index e4f7ca8..3312c76 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ build/ dist/ wheels/ *.egg-info +.cache* +*.log # Virtual environments .venv +.env \ No newline at end of file diff --git a/config.py b/config.py index 5bd6ff5..41abf30 100644 --- a/config.py +++ b/config.py @@ -14,29 +14,90 @@ CRITICAL: When calling tools, use COMPACT JSON with NO SPACES: { "name": "get_weather", "description": "Get current weather for a location", + "parameters"[{"city":"string"}], "examples": [ - { - "input": {"tool":"get_weather","parameters":{"city":"New York"}}, - "output": {"temperature": 22, "condition": "partly cloudy", "humidity": 65} - }, - { - "input": {"tool":"get_weather","parameters":{"city":"London"}}, - "output": {"temperature": 18, "condition": "rainy", "humidity": 80} - } + {"tool":"get_weather","parameters":{"city":"London"}}, + {"tool":"get_weather","parameters":{"city":"Kettering"}}, + {"tool":"get_weather","parameters":{"city":"Peterborough"}}, ] }, { "name": "find_folder", "description": "Find any folder that matches the name provided on your machine or an optional directory", + "parameters"[{"folder_name":"string"}], "examples": [ - { - "input": {"tool":"find_folder","parameters":{"folder_name":"devin"}}, - }, - { - "input": {"tool":"find_folder","parameters":{"folder_name":"winutils"}}, - } + {"tool":"find_folder","parameters":{"folder_name":"devin"}}, + {"tool":"find_folder","parameters":{"folder_name":"winutils"}}, + {"tool":"find_folder","parameters":{"folder_name":"Dygma"}}, ] -}''' +}, +{ + "name": "turn_on_light", + "description": "Turn on any light in the house by name", + "parameters"[{"light_name":"string"}], + "examples": [ + {"tool":"turn_on_light","parameters":{"light_name":"Monkey"}}, + {"tool":"turn_on_light","parameters":{"light_name":"Bedside"}}, + {"tool":"turn_on_light","parameters":{"light_name":"Bookshelf"}}, + ] +}, +{ + "name": "turn_off_light", + "description": "Turn off any light in the house by name", + "parameters"[{"light_name":"string"}], + "examples": [ + {"tool":"turn_off_light","parameters":{"light_name":"Monkey"}}, + {"tool":"turn_off_light","parameters":{"light_name":"Bedside"}}, + {"tool":"turn_off_light","parameters":{"light_name":"Bookshelf"}}, + ] +}, +{ + "name": "set_light_brightness", + "description": "Set the brightness level of any light in the house by name", + "parameters": [ + {"light_name": "string"}, + {"brightness": "integer (1-100)"} + ], + "examples": [ + {"tool": "set_light_brightness", "parameters": {"light_name": "Monkey", "brightness": 25}}, + {"tool": "set_light_brightness", "parameters": {"light_name": "Bedside", "brightness": 50}}, + {"tool": "set_light_brightness", "parameters": {"light_name": "Bookshelf", "brightness": 75}}, + ] +}, +{ + "name": "turn_on_room", + "description": "Turn on any room of lights in the house by name", + "parameters"[{"room_name":"string"}], + "examples": [ + {"tool":"turn_on_room","parameters":{"room_name":"Office"}}, + {"tool":"turn_on_room","parameters":{"room_name":"Bedroom"}}, + {"tool":"turn_on_room","parameters":{"room_name":"Lounge"}}, + ] +}, +{ + "name": "turn_off_room", + "description": "Turn off any room of lights in the house by name", + "parameters"[{"room_name":"string"}], + "examples": [ + {"tool":"turn_off_room","parameters":{"room_name":"Kitchen"}}, + {"tool":"turn_off_room","parameters":{"room_name":"Lounge"}}, + {"tool":"turn_off_room","parameters":{"room_name":"Office"}}, + ] +}, +{ + "name": "set_room_brightness", + "description": "Set the brightness level of any room of lights in the house by name", + "parameters": [ + {"room_name": "string"}, + {"brightness": "integer (1-100)"} + ], + "examples": [ + {"tool": "set_room_brightness", "parameters": {"room_name": "Office", "brightness": 25}}, + {"tool": "set_room_brightness", "parameters": {"room_name": "Bedroom", "brightness": 50}}, + {"tool": "set_room_brightness", "parameters": {"room_name": "Kitchen", "brightness": 75}}, + ] +} +''' # Tool config # WMO Weather interpretation codes mapping @@ -70,3 +131,26 @@ WEATHER_CODE_MAP = { 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail" } + +LIGHT_NAMES = { + 'monkey': '9b128205-0a98-46b5-9234-2bd0be9fd009', + 'bench': 'b0fae364-78ed-4f99-86d0-6c65cbf792cd', + "bedside": '945298e2-96b6-4c61-a506-1691bf7d7989', + 'bookshelf': '96d805e6-f39a-4e5f-9bde-3a42cbadfc6c', + 'lounge 1': '3ffcd59a-a19d-4066-af35-b5f45a2cf946', + 'lounge 2': 'facf3d02-f88d-482c-acb1-f9a4ed519356', + 'kitchen 1': 'badef93d-10ab-437d-9fc2-a09181a08fae', + 'kitchen 2': 'f15db9c5-0b67-49a6-8687-f20d768048b7', + 'kitchen 3': '47be084d-9e35-4108-aa4d-8da8f13e7d42', + 'kitchen 4': 'be408308-b14e-4bc3-9065-68055fd74b68', + 'shelf': '296ff923-da19-43a5-b1b2-7de25b227469', + 'cupboards': 'fe27cf93-68e2-47f2-a39e-d9bc4650263b', + 'office white': 'a1e0f26b-90b8-4044-98a3-58ed6a0f84b0', +} +ROOM_NAMES = { + 'office': 'bb0856ac-81d9-439a-83dc-8703c90574ba', + 'bedroom': '621fea30-f8b6-4de9-a347-1b4436321398', + 'lounge': '3dc9aab6-6379-4fa4-8e96-aae94fa692cf', + 'Kitchen': 'eaa524bc-edb6-4fd8-89b7-7cfc563ed7f1', + 'graveyard': 'aec3c969-581f-45c4-8f8f-b9407ee8caa3', +} \ No newline at end of file diff --git a/main.py b/main.py index 5f2bcca..af7bb6f 100644 --- a/main.py +++ b/main.py @@ -11,8 +11,6 @@ from llm_interface import llm import logging -#TODO: add context for llm calls so we have history and can chain messages - console = Console() class RichConsoleHandler(logging.StreamHandler): @@ -45,9 +43,10 @@ def main(logger): logger.info(f"Response equals original: {handled_response == llm_reply}") if handled_response != llm_reply: - output = language_model.tool_response(prompt=f'Make this lovely markdown, use fun emojis {handled_response}') - #TODO: Make sure to pass the history into the this so that if we ask additional questions it has context - # For example get weather AND suggest clothing based on weather. + output = language_model.tool_response(prompt=f'''Your original Request was: {llm_prompt}, +This is the tool reply: {handled_response} +Make a markdown format reply, using fun emojis. +''') elif handled_response == llm_reply: output = handled_response diff --git a/pyproject.toml b/pyproject.toml index e6c5a26..8d9e62b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "openmeteo-requests>=1.7.2", "pydantic>=2.11.7", "pytest>=8.4.1", + "python-dotenv>=1.1.1", "requests>=2.32.5", "requests-cache>=1.2.1", "retry-requests>=2.0.0", diff --git a/tools.py b/tools.py index b1f441a..6d56241 100644 --- a/tools.py +++ b/tools.py @@ -7,45 +7,64 @@ import config import logging import os import subprocess +import dotenv +import urllib3 + +dotenv.load_dotenv() + +# Suppress SSL warnings (this is fine for local network) +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) class ToolExecutor: def __init__(self, logger): - self.tools = { + self.available_tools = { "get_weather": self.get_weather, - "find_folder": self.find_folder + "find_folder": self.find_folder, + "turn_on_light": self.turn_on_light, + "turn_off_light": self.turn_off_light, + "set_light_brightness": self.set_light_brightness, + "turn_on_room": self.turn_on_room, + "turn_off_room": self.turn_off_room, + "set_room_brightness": self.set_room_brightness } self.logger = logger + self.bridge_ip = '192.168.0.152' + self.app_key = os.getenv("USERNAME") + self.base_url = f'https://{self.bridge_ip}/clip/v2/resource' + self.session = requests.Session() + self.session.verify = False # For local network devices - def process_llm_response(self, llm_output): + + def process_llm_response(self, llm_output): if llm_output.startswith('{"tool":'): # Parse the tool call from LLM response llm_output = json.loads(llm_output) - + self.logger.info(f"Parsed LLM output: {llm_output}") tool_name = llm_output.get("tool",'no tool') parameters = llm_output.get("parameters", 'no parameters') self.logger.info(f'parsed tool: {tool_name}, parsed parameters: {parameters}') - + # Execute the actual function - result = self.tools[tool_name](**parameters) - + result = self.available_tools[tool_name](**parameters) + # Return result for LLM to use return result return llm_output - + def get_weather(self, city: str, units: str = "celsius"): """Get current weather for a location using Open-Meteo API with openmeteo-requests library.""" - + self.logger.info(f'get weather called, city = {city}') if not city: raise ValueError("City parameter is required") - + try: # Setup the Open-Meteo API client with cache and retry on error cache_session = requests_cache.CachedSession('.cache', expire_after=3600) retry_session = retry(cache_session, retries=5, backoff_factor=0.2) openmeteo = openmeteo_requests.Client(session=retry_session) - + # Step 1: Get coordinates for the city using geocoding API geocoding_url = "https://geocoding-api.open-meteo.com/v1/search" geocoding_params = { @@ -54,33 +73,33 @@ class ToolExecutor: "language": "en", "format": "json" } - + geo_response = requests.get(geocoding_url, params=geocoding_params, timeout=10) geo_response.raise_for_status() geo_data = geo_response.json() self.logger.info(f'Geo data for city: {geo_data}') - + if not geo_data.get("results"): raise Exception(f"City '{city}' not found") - + location = geo_data["results"][0] latitude = location["latitude"] longitude = location["longitude"] city_name = location["name"] country = location.get("country", "Unknown") - + # Step 2: Get weather data using openmeteo-requests url = "https://api.open-meteo.com/v1/forecast" - + # Convert temperature units for the API temperature_unit = "celsius" if units == "celsius" else "fahrenheit" - + params = { "latitude": latitude, "longitude": longitude, "current": [ "temperature_2m", - "relative_humidity_2m", + "relative_humidity_2m", "weather_code", "surface_pressure", "wind_speed_10m" @@ -88,7 +107,7 @@ class ToolExecutor: "temperature_unit": temperature_unit, "wind_speed_unit": "kmh" } - + responses = openmeteo.weather_api(url, params=params) response = responses[0] self.logger.info(f'Weather API response: {response}') @@ -96,16 +115,16 @@ class ToolExecutor: # Get current weather data current = response.Current() self.logger.info(f'Current weather data: {current}') - + # Extract values using the library's methods temperature = current.Variables(0).Value() # temperature_2m humidity = current.Variables(1).Value() # relative_humidity_2m weather_code = int(current.Variables(2).Value()) # weather_code pressure = current.Variables(3).Value() # surface_pressure wind_speed = current.Variables(4).Value() # wind_speed_10m - + condition = config.WEATHER_CODE_MAP.get(weather_code, "Unknown") - + json_reply = { "temperature": temperature, @@ -120,41 +139,84 @@ class ToolExecutor: } self.logger.info(f"Weather data result: {json_reply}") return json_reply - + except Exception as e: raise Exception(f"Weather lookup failed: {str(e)}") def find_folder(self, folder_name: str, search_path: str = "/home/"): """Search for folders with a specific name on the PC.""" - + self.logger.info(f'find_folder called, folder_name = {folder_name}, search_path = {search_path}') - + if not folder_name: raise ValueError("folder_name parameter is required") - + try: found_folders = [] # Use find command to search for directories cmd = ['find', search_path, '-type', 'd', '-name', folder_name] result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - self.logger.warning(result) + self.logger.info(result) if result.stdout: found_folders = [path.strip() for path in result.stdout.split('\n') if path.strip()] - + # Limit results to first 50 to avoid overwhelming output found_folders = found_folders[:50] - + json_reply = { "folder_name": folder_name, "search_path": search_path, "found_folders": found_folders, "count": len(found_folders) } - + self.logger.info(f"Folder search result: {json_reply}") return json_reply - + except subprocess.TimeoutExpired: raise Exception("Folder search timed out after 30 seconds") except Exception as e: - raise Exception(f"Folder search failed: {str(e)}") \ No newline at end of file + raise Exception(f"Folder search failed: {str(e)}") + + def tell_hue(self, endpoint, payload): + headers = {"hue-application-key": self.app_key} + api_path = self.base_url+endpoint + return self.session.put(api_path, json=payload, headers=headers, timeout=10) + + def control_light(self, light_name, on_state=True, brightness=None): + """Control a light""" + endpoint = f'/light/{config.LIGHT_NAMES[light_name.lower()]}' + data = {"on": {"on": on_state}} + if brightness is not None: + data["dimming"] = { + "brightness": brightness + } + return self.tell_hue(endpoint,data) + + def control_room(self, room_name, on_state=True, brightness=None): + """Control a light""" + endpoint = f'/grouped_light/{config.ROOM_NAMES[room_name.lower()]}' + data = {"on": {"on": on_state}} + if brightness is not None: + data["dimming"] = { + "brightness": brightness + } + return self.tell_hue(endpoint,data) + + def turn_on_light(self, light_name): + return self.control_light(light_name) + + def turn_off_light(self, light_name): + return self.control_light(light_name, on_state=False) + + def set_light_brightness(self, light_name, brightness): + return self.control_light(light_name, on_state=True, brightness=brightness) + + def turn_on_room(self, room_name): + return self.control_room(room_name) + + def turn_off_room(self, room_name): + return self.control_room(room_name, on_state=False) + + def set_room_brightness(self, room_name, brightness): + return self.control_room(room_name, on_state=True, brightness=brightness) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 19dbe97..62f5473 100644 --- a/uv.lock +++ b/uv.lock @@ -116,6 +116,7 @@ dependencies = [ { name = "openmeteo-requests" }, { name = "pydantic" }, { name = "pytest" }, + { name = "python-dotenv" }, { name = "requests" }, { name = "requests-cache" }, { name = "retry-requests" }, @@ -130,6 +131,7 @@ requires-dist = [ { name = "openmeteo-requests", specifier = ">=1.7.2" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pytest", specifier = ">=8.4.1" }, + { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "requests", specifier = ">=2.32.5" }, { name = "requests-cache", specifier = ">=1.2.1" }, { name = "retry-requests", specifier = ">=2.0.0" }, @@ -382,6 +384,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + [[package]] name = "qh3" version = "1.5.4"