├── .github └── workflows │ └── docker.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── config.auth.sample.json ├── config.sample.json ├── main.py ├── plugins ├── calendar.py ├── homeassistant.py └── weather.py └── requirements.txt /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | push: 5 | branches: [ master, staging ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: read 13 | packages: write 14 | 15 | steps: 16 | - name: Check out the code 17 | uses: actions/checkout@v2 18 | 19 | - name: Log in to GitHub Packages 20 | uses: docker/login-action@v1 21 | with: 22 | registry: ghcr.io 23 | username: ${{ github.actor }} 24 | password: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: Extract branch name 27 | shell: bash 28 | run: echo "BRANCH_NAME=$(echo ${GITHUB_REF#refs/heads/})" >> $GITHUB_ENV 29 | 30 | - name: Build and push Docker image 31 | uses: docker/build-push-action@v2 32 | with: 33 | context: ./ 34 | push: true 35 | tags: ghcr.io/johnthenerd/homeassistant-llm-prompt-generator:${{ env.BRANCH_NAME }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.json -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY requirements.txt ./ 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY . . 9 | 10 | EXPOSE 8000 11 | 12 | CMD [ "python", "./main.py" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HomeAssistant LLM Prompt Generator 2 | 3 | **I have rewritten my entire RAG code into a fork of openai_conversation instead of using extended_openai_conversation, which can be found [here](https://github.com/JohnTheNerd/rag_openai_conversation). It no longer needs a RAG API. I will no longer be maintaining this repository.** 4 | 5 | # Introduction 6 | 7 | The current de-facto method of using LLMs to automate a smart home involves sending *the entire smart home state* as part of the context. This is insanely slow for local LLM's (especially if you are running without GPUs, as prefill times tend to be llama.cpp's bottleneck), and can get expensive over time for cloud LLM API's. However, in practice, most of this state is not even relevant to what you just asked your assistant! 8 | 9 | This repository implements RAG (Retrieval Augmented Generation) to optimize the state that is sent in the first place, which massively reduces the amount of information we need to feed the LLM's. I decided to make this a separate API instead of adding even more logic to the forked HomeAssistant integration in order to more easily add additional information that is not accessible to HomeAssistant, and it honestly just works better with my home infrastructure. 10 | 11 | # Usage 12 | 13 | - Clone this repository 14 | 15 | - Copy `config.sample.json` (`config.auth.sample.json` if you would like authentication) to `config.json` and update all fields accordingly. Alternatively, set the environment variable `CONFIG_PATH` to where your configuration is located. 16 | 17 | - If you are using a cloud embedding model, beware that the update function re-embeds everything each time, even if the values are static. You may want to set a large `update_interval` or monitor your costs. I'm sure it's not too difficult to cache matching embeddings either. PR's are very welcome! 18 | 19 | - If you do not want in-context learning via examples, simply disable `include_examples`. Keep in mind that the examples are dynamically generated, do not require any additional configuration, and can be quite useful! 20 | 21 | - If authentication is used: 22 | 23 | - All requests are required to have an `Authorization: Bearer ` HTTP header and will otherwise receive a HTTP 401. 24 | 25 | - You can override plugins per user, as you can see in the example. 26 | 27 | Furthermore, if authentication is enabled, the HomeAssistant side of this setup will also need modifications. In particular, you will need to update `/config/secrets.yaml` to have a new field that looks like this (user names must match what is defined in HomeAssistant): 28 | 29 | ``` 30 | rag_api_tokens: 31 | User1: "User-1-Token-Here" 32 | User2: "User-2-Token-Here" 33 | ``` 34 | 35 | - Run `pip3 install -r requirements.txt` (or build/run the Docker image from the Dockerfile) 36 | 37 | - Run `python3 main.py` and the API should be available on port 8000. The only endpoint available is `/prompt` which is a POST that expects a JSON body. The JSON body should have `user_prompt` set as the user prompt. 38 | 39 | # Plugins 40 | 41 | ## Calendar 42 | 43 | Reads a number of calendars via CalDAV. Returns all events in the next week. Since it creates a lot of examples, it will also randomly sample them as an attempt to reduce the number of tokens we send to the LLM. You can set `example_count` to the maximum number of examples you wish to have (some are conditional, so you cannot know exactly how many examples will be returned at any time). 44 | 45 | The plugin expects a list of objects at `calendars`. Each calendar object must have a `url` which is the CalDAV URL. It can optionally have a `username` and `password` for HTTP Basic Authentication. 46 | 47 | 48 | ## HomeAssistant 49 | 50 | All fields in the sample configuration are required. This plugin has several functions, all of which can be toggled from the config file: 51 | 52 | - Areas: All entities belonging to all devices in each area. This is grouped by area, where each area is a separate document to be searched for. The LLM prompt addition is the states of all devices, and there are a few examples with lights. 53 | 54 | - Shopping list: The shopping list, as defined in HomeAssistant. Includes examples to add/remove items from the shopping list. It is recommended you keep these examples as I found that LLM's otherwise tend to find it hard to manipulate the shopping list. 55 | 56 | - Person: Defines every person and whether they are home. No examples as it is very self-explanatory. 57 | 58 | - Music Assistant: Provides a few examples to use the mass.play_media service. Does not augment the LLM prompt itself. 59 | 60 | - Laundry and Color Loop: Currently extremely custom and is mostly meant for me to use. Feel free to use them if they help you, but it's likely that you will need to change the templates. 61 | 62 | `ignored_entities` ignores the entities given in the list. It is a substring search. If you want all entities to be part of the LLM prompt, simply make it an empty list. 63 | 64 | 65 | ## Weather 66 | 67 | Currently only pulls information from Environment Canada. 68 | 69 | Expects either a `station_id` or `coordinates` as defined in the [PyPI page of env-canada](https://pypi.org/project/env-canada/). Returns the current weather summary and the forecast for the next week. Does not include any examples. 70 | 71 | 72 | # Creating plugins 73 | 74 | Plugins are merely Python scripts that are in the plugins directory. You must define a class named `Adapter` and the following functions: 75 | 76 | - `__init__()`: Initialization code. Set arguments of `config` and `utils`. `config` will contain the plugin configuration as a dictionary, and `utils` is a dictionary consisting of functions to get embeddings of any text (`get_embedding` and `get_embedding_async`) and get cosine similarity of two sets of embeddings (`compute_similarity`). 77 | 78 | - `update()`: This will run every once in a while, at an interval determined by the user. Do not accept any arguments. You should use this to cache as much information as possible, as it runs in the background. 79 | 80 | - `get_documents()`: This is where you return all the "documents" for RAG. Do not accept any arguments. The user prompt will be queried against your documents. This should run as fast as possible, ideally only returning an object you created and cached by `update()`. You must return a dictionary with `title` as what the prompt should be searched against, and `embedding` as the embedding of it. You may add additional information to help you in the function below, as you will receive that object back. The best way to get the embeddings is to call `utils['get_embedding'](your_title)` from `update()` and cache it locally. 81 | 82 | - `get_llm_prompt_addition()`: This is where you return the LLM prompt (and optionally, examples). It is only called if the user prompt is determined to require your plugin's input. Accept two arguments, `document` and `user_prompt`. `document` is one of the documents you returned from `get_documents()` and `user_prompt` is merely the prompt that was received from the user. This should still run reasonably quickly, but don't have to be as cautious as `get_documents()`. You must return a dictionary with `prompt` set to the text you would like to append to the LLM prompt, and `examples` as a list of tuples. The tuples should be (question, answer). If you do not need in-context learning in your plugin, simply return `examples` as an empty list. 83 | -------------------------------------------------------------------------------- /config.auth.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "embedding_api_key": "sk-apikey", 3 | "embedding_base_url": "https://api.openai.com/v1", 4 | "embedding_model": "text-embedding-large", 5 | "number_of_results": 3, 6 | "log_level": 20, 7 | "include_examples": true, 8 | "update_interval": 3600, 9 | "plugins": { 10 | "homeassistant": { 11 | "access_token": "homeassistant.api.key", 12 | "base_url": "http://homeassistant.local:8123", 13 | "ignored_entities": [ 14 | "_power_source", 15 | "_learned_ir_code", 16 | "_sensor_battery", 17 | "_hooks_state", 18 | "_motor_state", 19 | "_target_position", 20 | "_button_action", 21 | "_vibration_sensor_x_axis", 22 | "_vibration_sensor_y_axis", 23 | "_vibration_sensor_z_axis", 24 | "_vibration_sensor_angle_x", 25 | "_vibration_sensor_angle_y", 26 | "_vibration_sensor_angle_z", 27 | "_vibration_sensor_device_temperature", 28 | "_vibration_sensor_action", 29 | "_vibration_sensor_power_outage_count", 30 | "update.", 31 | "_motion_sensor_sensitivity", 32 | "_motion_sensor_keep_time", 33 | "_motion_sensor_sensitivity", 34 | "_curtain_driver_left_hooks_lock", 35 | "_curtain_driver_right_hooks_lock", 36 | "sensor.cgllc_cgd1st_9254_charging_state", 37 | "sensor.cgllc_cgd1st_9254_voltage", 38 | "_curtain_driver_left_hand_open", 39 | "_curtain_driver_right_hand_open", 40 | "_curtain_driver_left_device_temperature", 41 | "curtain_driver_right_device_temperature", 42 | "_curtain_driver_left_running", 43 | "_curtain_driver_right_running", 44 | "_update_available" 45 | ], 46 | "areas_enabled": true, 47 | "shopping_list_enabled": true, 48 | "laundry_enabled": false, 49 | "media_player_enabled": true, 50 | "person_enabled": true, 51 | "color_loop_enabled": false, 52 | "music_assistant_enabled": true 53 | }, 54 | "weather": { 55 | "station_id": "NL/s0000280" 56 | }, 57 | "calendar": { 58 | "example_count": 2, 59 | "calendars": [ 60 | { 61 | "url": "https://example.com/caldav" 62 | } 63 | ] 64 | } 65 | }, 66 | "users": { 67 | "User1": { 68 | "token": "User-1-Token-Here", 69 | "plugins": { 70 | "calendar": { 71 | "example_count": 2, 72 | "calendars": [ 73 | { 74 | "url": "https://another-example.com/caldav" 75 | } 76 | ] 77 | } 78 | } 79 | }, 80 | "User2": { 81 | "token": "User-2-Token-Here" 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /config.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "embedding_api_key": "sk-apikey", 3 | "embedding_base_url": "https://api.openai.com/v1", 4 | "embedding_model": "text-embedding-large", 5 | "number_of_results": 3, 6 | "log_level": 20, 7 | "include_examples": true, 8 | "update_interval": 3600, 9 | "plugins": { 10 | "homeassistant": { 11 | "access_token": "homeassistant.api.key", 12 | "base_url": "http://homeassistant.local:8123", 13 | "ignored_entities": [ 14 | "_power_source", 15 | "_learned_ir_code", 16 | "_sensor_battery", 17 | "_hooks_state", 18 | "_motor_state", 19 | "_target_position", 20 | "_button_action", 21 | "_vibration_sensor_x_axis", 22 | "_vibration_sensor_y_axis", 23 | "_vibration_sensor_z_axis", 24 | "_vibration_sensor_angle_x", 25 | "_vibration_sensor_angle_y", 26 | "_vibration_sensor_angle_z", 27 | "_vibration_sensor_device_temperature", 28 | "_vibration_sensor_action", 29 | "_vibration_sensor_power_outage_count", 30 | "update.", 31 | "_motion_sensor_sensitivity", 32 | "_motion_sensor_keep_time", 33 | "_motion_sensor_sensitivity", 34 | "_curtain_driver_left_hooks_lock", 35 | "_curtain_driver_right_hooks_lock", 36 | "sensor.cgllc_cgd1st_9254_charging_state", 37 | "sensor.cgllc_cgd1st_9254_voltage", 38 | "_curtain_driver_left_hand_open", 39 | "_curtain_driver_right_hand_open", 40 | "_curtain_driver_left_device_temperature", 41 | "curtain_driver_right_device_temperature", 42 | "_curtain_driver_left_running", 43 | "_curtain_driver_right_running", 44 | "_update_available" 45 | ], 46 | "areas_enabled": true, 47 | "shopping_list_enabled": true, 48 | "laundry_enabled": false, 49 | "media_player_enabled": true, 50 | "person_enabled": true, 51 | "color_loop_enabled": false, 52 | "music_assistant_enabled": false 53 | }, 54 | "weather": { 55 | "station_id": "NL/s0000280" 56 | }, 57 | "calendar": { 58 | "example_count": 2, 59 | "calendars": [ 60 | { 61 | "url": "https://example.com/caldav" 62 | } 63 | ] 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | from fastapi import FastAPI, Depends, HTTPException, Body 3 | from fastapi.responses import JSONResponse 4 | from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 5 | import os 6 | import importlib.util 7 | import json 8 | import logging 9 | import numpy as np 10 | import requests 11 | import time 12 | import threading 13 | from typing import Optional 14 | 15 | app = FastAPI() 16 | 17 | config_path = os.environ.get('CONFIG_PATH', 'config.json') 18 | 19 | with open(config_path) as f: 20 | config = json.load(f) 21 | users_config = config.get('users', {}) 22 | 23 | logging.basicConfig(level=config['log_level'], format='%(asctime)s - %(levelname)s - %(message)s') 24 | logger = logging.getLogger(__name__) 25 | 26 | api_key = config['embedding_api_key'] 27 | 28 | def compute_similarity(first, second): 29 | dot_product = np.dot(first, second) 30 | magnitude_product = np.linalg.norm(first) * np.linalg.norm(second) 31 | cosine_similarity = dot_product / magnitude_product 32 | return cosine_similarity 33 | 34 | def instantiate_plugins(directory, config): 35 | plugins = [] 36 | utils = { 37 | "get_embedding": get_embedding, 38 | "get_embedding_async": get_embedding_async, 39 | "compute_similarity": compute_similarity 40 | } 41 | if 'plugins' in config: 42 | for module_name in config['plugins']: 43 | spec = importlib.util.spec_from_file_location(module_name, os.path.join(directory, f'{module_name}.py')) 44 | module = importlib.util.module_from_spec(spec) 45 | spec.loader.exec_module(module) 46 | obj = getattr(module, 'Adapter') 47 | if hasattr(obj, "__class__") and callable(obj): 48 | plugin_config = config['plugins'].get(module_name, {}) 49 | plugin_class = obj(plugin_config, utils) 50 | plugins.append({ 51 | "name": module_name, 52 | "class": plugin_class 53 | }) 54 | return plugins 55 | 56 | def get_plugins(user_name): 57 | relevant_plugins = [] 58 | user_plugin_names = [] 59 | if not user_name: 60 | return plugins 61 | for user_plugin in plugins_by_user[user_name]: 62 | relevant_plugins.append(user_plugin) 63 | user_plugin_names.append(user_plugin['name']) 64 | for plugin in plugins: 65 | if plugin['name'] not in user_plugin_names: 66 | relevant_plugins.append(plugin) 67 | return relevant_plugins 68 | 69 | def update_plugins_thread(): 70 | while True: 71 | time.sleep(config['update_interval']) 72 | update_plugins() 73 | 74 | def update_plugins(): 75 | for plugin in plugins: 76 | try: 77 | plugin["class"].update() 78 | except Exception as e: 79 | logger.error(f"Error updating plugin {plugin['name']}: {str(e)}") 80 | 81 | if plugins_by_user: 82 | for user_name in plugins_by_user: 83 | for plugin in plugins_by_user[user_name]: 84 | try: 85 | plugin["class"].update() 86 | except Exception as e: 87 | logger.error(f"Error updating plugin {plugin['name']} for user {user_name}: {str(e)}") 88 | 89 | def get_embedding(prompt): 90 | headers = {"Authorization": f"Bearer {api_key}"} 91 | data = {"model": config['embedding_model'], "input": prompt} 92 | response = requests.post(f"{config['embedding_base_url']}/embeddings", headers=headers, json=data, timeout=10) 93 | 94 | if response.status_code == 200: 95 | embedding = response.json()["data"] 96 | return embedding[0]['embedding'] 97 | else: 98 | logger.error(f"Error: {response.status_code}") 99 | return response 100 | 101 | async def get_embedding_async(prompt): 102 | async with aiohttp.ClientSession() as session: 103 | headers = {"Authorization": f"Bearer {api_key}"} 104 | data = {"model": config['embedding_model'], "input": prompt} 105 | async with session.post(f"{config['embedding_base_url']}/embeddings", headers=headers, json=data) as response: 106 | if response.status == 200: 107 | embedding = await response.json() 108 | return embedding["data"][0]['embedding'] 109 | else: 110 | logger.error(f"Error: {response.status}") 111 | return response 112 | 113 | def compute_similarity(first, second): 114 | dot_product = np.dot(first, second) 115 | magnitude_product = np.linalg.norm(first) * np.linalg.norm(second) 116 | cosine_similarity = dot_product / magnitude_product 117 | return cosine_similarity 118 | 119 | def compute_plugin_similarities(prompt_embedding, plugins_to_use): 120 | similarities = [] 121 | for plugin in plugins_to_use: 122 | plugin_name = plugin['name'] 123 | plugin_class = plugin['class'] 124 | documents = plugin_class.get_documents() 125 | for document in documents: 126 | document_embedding = document['embedding'] 127 | similarity = compute_similarity(prompt_embedding, document_embedding) 128 | similarities.append({ 129 | "document": document, 130 | "similarity": similarity, 131 | "plugin_name": plugin_name 132 | }) 133 | return similarities 134 | 135 | 136 | async def process_prompt(user_prompt, plugins_to_use): 137 | prompt_embedding = await get_embedding_async(user_prompt) 138 | similarities = compute_plugin_similarities(prompt_embedding, plugins_to_use) 139 | logger.debug(f'similarities: {similarities}') 140 | for similarity in similarities: 141 | logger.debug(f'cosine similarity between "{user_prompt}" and "{similarity["document"]["title"]}" is {similarity["similarity"]}') 142 | similarities.sort(key=lambda x: x['similarity'], reverse=True) 143 | selected_results = similarities[:config['number_of_results']] 144 | llm_prompt = "" 145 | examples = [] 146 | for result in selected_results: 147 | document_title = result['document']['title'] 148 | similarity = result['similarity'] 149 | plugin_name = result['plugin_name'] 150 | logger.debug(f'selected "{document_title}" with a cosine similarity of {similarity}') 151 | for plugin in plugins_to_use: 152 | if plugin['name'] == plugin_name: 153 | plugin_class = plugin['class'] 154 | prompt_addition = plugin_class.get_llm_prompt_addition(result['document'], user_prompt) 155 | logger.debug(f'prompt_addition: {prompt_addition}') 156 | llm_prompt = llm_prompt + prompt_addition['prompt'].strip() 157 | llm_prompt = llm_prompt + '\n\n\n' 158 | for example in prompt_addition['examples']: 159 | examples.append(example) 160 | if 'include_examples' in config and config['include_examples'] == True: 161 | if examples: 162 | llm_prompt = llm_prompt.strip() + '\n\n\nFind examples below. Reword the answers to fit your personality. Prompts are given as Q: and the example answers are given as A:\n\n' 163 | for example in examples: 164 | question = example[0] 165 | answer = example[1] 166 | llm_prompt = f'{llm_prompt}Q:{question}\nA:{answer}\n\n' 167 | llm_prompt = llm_prompt.strip() 168 | return llm_prompt 169 | 170 | @app.post("/prompt") 171 | async def process_prompt_endpoint( 172 | user_prompt: str = Body(..., embed=True), 173 | credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer()) if users_config else None, 174 | ): 175 | user_name = None 176 | if users_config: 177 | for user, user_data in users_config.items(): 178 | if credentials.scheme.lower() == 'bearer' and credentials.credentials == user_data['token']: 179 | user_name = user 180 | break 181 | else: 182 | raise HTTPException(status_code=401, detail='Unauthorized') 183 | plugins_to_use = get_plugins(user_name) 184 | llm_prompt = await process_prompt(user_prompt, plugins_to_use) 185 | return JSONResponse(content={"prompt": llm_prompt}, media_type="application/json") 186 | 187 | @app.post("/update") 188 | async def update_plugins_endpoint( 189 | credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer()) if users_config else None, 190 | ): 191 | if users_config: 192 | for user, user_data in users_config.items(): 193 | if credentials.scheme.lower() == 'bearer' and credentials.credentials == user_data['token']: 194 | break 195 | else: 196 | raise HTTPException(status_code=401, detail='Unauthorized') 197 | update_plugins() 198 | return JSONResponse(content={"success": True}, media_type="application/json") 199 | 200 | plugins_directory = "plugins" 201 | plugins = instantiate_plugins(plugins_directory, config) 202 | plugins_by_user = {} 203 | if users_config: 204 | for user_name in users_config: 205 | user_data = users_config[user_name] 206 | plugins_by_user[user_name] = instantiate_plugins(plugins_directory, user_data) 207 | 208 | if __name__ == "__main__": 209 | update_plugins() 210 | update_thread = threading.Thread(target=update_plugins_thread) 211 | update_thread.daemon = True 212 | update_thread.start() 213 | logger.info("Update started, starting API") 214 | import uvicorn 215 | uvicorn.run(app, host="0.0.0.0", port=8000) -------------------------------------------------------------------------------- /plugins/calendar.py: -------------------------------------------------------------------------------- 1 | import icalendar 2 | import requests 3 | import datetime 4 | import random 5 | import tzlocal 6 | import dateutil.rrule 7 | 8 | class Adapter: 9 | def __init__(self, config, utils): 10 | self.utils = utils 11 | self.calendar_configuration = config['calendars'] 12 | self.example_count = config.get('example_count', 1) 13 | self.local_tz = tzlocal.get_localzone() 14 | self.calendars = {} 15 | self.calendar_events = [] 16 | self.documents = [] 17 | 18 | def update(self): 19 | for calendar in self.calendar_configuration: 20 | caldav_url = calendar.get('url') 21 | username = calendar.get('username') 22 | password = calendar.get('password') 23 | session = requests.Session() 24 | session.auth = (username, password) 25 | response = session.get(caldav_url, timeout=10) 26 | calendar_object = icalendar.Calendar.from_ical(response.text) 27 | self.calendars[caldav_url] = calendar_object 28 | self.calendar_events = [] 29 | title = "All calendar events (meetings, appointments, tasks) for the next week:" 30 | for calendar in self.calendars.keys(): 31 | calendar_obj = self.calendars[calendar] 32 | # localize the date time 33 | now = datetime.datetime.now(self.local_tz) 34 | # for now, let's just work with the next week of events 35 | start = now 36 | end = now + datetime.timedelta(days=7) 37 | for component in calendar_obj.walk(): 38 | if component.name == "VEVENT": 39 | event_start = component.get("DTSTART").dt 40 | if isinstance(event_start, datetime.date) and not isinstance(event_start, datetime.datetime): 41 | event_start = datetime.datetime.combine(event_start, datetime.time(0, tzinfo=self.local_tz)) 42 | if hasattr(event_start, 'astimezone'): 43 | event_start = event_start.astimezone(self.local_tz) 44 | event_end = component.get("DTEND").dt 45 | if isinstance(event_end, datetime.date) and not isinstance(event_end, datetime.datetime): 46 | event_end = datetime.datetime.combine(event_end, datetime.time(0, tzinfo=self.local_tz)) 47 | if hasattr(event_end, 'astimezone'): 48 | event_end = event_end.astimezone(self.local_tz) 49 | if start <= event_start < end: 50 | self.calendar_events.append(component) 51 | # handle recurring events 52 | rrule_attr = component.get("RRULE") 53 | if rrule_attr: 54 | rrule_string = rrule_attr.to_ical().decode() 55 | params = rrule_string.split(";") 56 | freq = None 57 | interval = 1 58 | byweekday = None 59 | until = None 60 | bymonthday = None 61 | bymonth = None 62 | for param in params: 63 | if param.startswith("FREQ="): 64 | freq = param.split("=")[1] 65 | elif param.startswith("INTERVAL="): 66 | interval = int(param.split("=")[1]) 67 | elif param.startswith("BYDAY="): 68 | byweekday = param.split("=")[1] 69 | elif param.startswith("UNTIL="): 70 | until = param.split("=")[1] 71 | elif param.startswith("BYMONTHDAY="): 72 | bymonthday = int(param.split("=")[1]) 73 | elif param.startswith("BYMONTH="): 74 | bymonth = int(param.split("=")[1]) 75 | if until: 76 | until_date = datetime.datetime.strptime(until, "%Y%m%dT%H%M%SZ").date() 77 | until_date = datetime.datetime.combine(until_date, datetime.time(0, tzinfo=self.local_tz)) 78 | else: 79 | until_date = datetime.datetime.now(self.local_tz) + datetime.timedelta(days=400) 80 | if freq == "DAILY": 81 | rrule_set = dateutil.rrule.rrule(dateutil.rrule.DAILY, interval=interval, dtstart=event_start, until=until_date) 82 | elif freq == "WEEKLY": 83 | weekday_mapping = { 84 | 'MO': dateutil.rrule.MO, 85 | 'TU': dateutil.rrule.TU, 86 | 'WE': dateutil.rrule.WE, 87 | 'TH': dateutil.rrule.TH, 88 | 'FR': dateutil.rrule.FR, 89 | 'SA': dateutil.rrule.SA, 90 | 'SU': dateutil.rrule.SU, 91 | } 92 | weekdays = [] 93 | if byweekday: 94 | for weekday_str in byweekday.split(','): 95 | weekdays.append(weekday_mapping[weekday_str.upper()]) 96 | else: 97 | # we have no choice but to assume it's the day of week in the current occurrence 98 | weekday_of_event = event_start.weekday() 99 | recurrence_weekday = list(weekday_mapping.values())[weekday_of_event] 100 | weekdays.append(recurrence_weekday) 101 | 102 | rrule_set = dateutil.rrule.rrule(dateutil.rrule.WEEKLY, interval=interval, dtstart=event_start, byweekday=weekdays, until=until_date) 103 | elif freq == "MONTHLY": 104 | rrule_set = dateutil.rrule.rrule(dateutil.rrule.MONTHLY, interval=interval, dtstart=event_start, until=until_date) 105 | elif freq == "YEARLY": 106 | if bymonthday and bymonth: 107 | rrule_set = dateutil.rrule.rrule(dateutil.rrule.YEARLY, interval=interval, dtstart=event_start, bymonth=bymonth, bymonthday=bymonthday, until=until_date) 108 | elif bymonthday: 109 | rrule_set = dateutil.rrule.rrule(dateutil.rrule.YEARLY, interval=interval, dtstart=event_start, bymonthday=bymonthday, until=until_date) 110 | elif bymonth: 111 | rrule_set = dateutil.rrule.rrule(dateutil.rrule.YEARLY, interval=interval, dtstart=event_start, bymonth=bymonth, until=until_date) 112 | else: 113 | rrule_set = dateutil.rrule.rrule(dateutil.rrule.YEARLY, interval=interval, dtstart=event_start, until=until_date) 114 | else: 115 | print(f"Unsupported frequency for rule {rrule_string}") 116 | continue 117 | for recurring_event_start in rrule_set.between(before=end, after=datetime.datetime.now(self.local_tz), inc=True): 118 | # create a new VEVENT component for the recurring event 119 | recurring_event = icalendar.Event() 120 | recurring_event.add("DTSTART", recurring_event_start) 121 | recurring_event.add("DTEND", recurring_event_start + (event_end - event_start)) 122 | recurring_event.add("SUMMARY", component.get("SUMMARY")) 123 | recurring_event.add("TITLE", component.get("TITLE")) 124 | self.calendar_events.append(recurring_event) 125 | 126 | if self.calendar_events: 127 | for event in self.calendar_events: 128 | event_start = event.get('DTSTART').dt 129 | if isinstance(event_start, datetime.date) and not isinstance(event_start, datetime.datetime): 130 | event_start = datetime.datetime.combine(event_start, datetime.time(0, tzinfo=self.local_tz)) 131 | event['DTSTART'].dt = event_start 132 | event_end = event.get('DTEND').dt 133 | # all day events should go until 00:00 the next day 134 | if isinstance(event_end, datetime.date) and not isinstance(event_end, datetime.datetime): 135 | event_end = datetime.datetime.combine(event_end, datetime.time(0, tzinfo=self.local_tz)) 136 | event_end = event_end + datetime.timedelta(days=1) 137 | event['DTEND'].dt = event_end 138 | self.calendar_events.sort(key=lambda x: x.get('DTSTART').dt) 139 | llm_prompt = f"{title}\n" 140 | for event in self.calendar_events: 141 | event_start = event.get("DTSTART").dt 142 | if isinstance(event_start, datetime.date) and not isinstance(event_start, datetime.datetime): 143 | event_start = datetime.datetime.combine(event_start, datetime.time(0, tzinfo=self.local_tz)) 144 | if hasattr(event_start, 'astimezone'): 145 | event_start = event_start.astimezone(self.local_tz) 146 | event_end = event.get("DTEND").dt 147 | if isinstance(event_end, datetime.date) and not isinstance(event_end, datetime.datetime): 148 | event_end = datetime.datetime.combine(event_end, datetime.time(0, tzinfo=self.local_tz)) 149 | if hasattr(event_end, 'astimezone'): 150 | event_end = event_end.astimezone(self.local_tz) 151 | 152 | event_day_of_week = event_start.strftime("%A") 153 | event_start_formatted = event_start.strftime('%I:%M %p') 154 | event_end_formatted = event_end.strftime('%I:%M %p') 155 | event_start_date_formatted = event_start.strftime('%B %-d') 156 | event_summary = event.get('SUMMARY') 157 | if not event_summary: 158 | event_summary = event.get('TITLE') 159 | llm_prompt = llm_prompt + '\n- ' + (f"{event_summary} between {event_start_formatted} and {event_end_formatted} on {event_day_of_week}, {event_start_date_formatted}") 160 | 161 | else: 162 | llm_prompt = f"{title}\n\nThere are no calendar events in the next week." 163 | 164 | self.llm_prompt = llm_prompt 165 | self.documents = [ 166 | { 167 | "title": self.llm_prompt, 168 | "embedding": self.utils['get_embedding'](self.llm_prompt) 169 | } 170 | ] 171 | 172 | def get_event_key(self, event): 173 | event_start = event.get("DTSTART").dt 174 | now = datetime.datetime.now(event_start.tzinfo) 175 | if isinstance(event_start, datetime.date) and not isinstance(event_start, datetime.datetime): 176 | event_start = datetime.datetime.combine(event_start, datetime.time(0)) 177 | return abs((event_start - now).total_seconds()) 178 | 179 | def get_documents(self): 180 | return self.documents 181 | 182 | def get_llm_prompt_addition(self, selected_categories, user_prompt): 183 | examples = [] 184 | if self.calendar_events: 185 | now = datetime.datetime.now(self.local_tz) 186 | # we can only reliably create three examples, so let's cap there for now 187 | # also, why would you want more than 3 calendar examples anyway? 188 | number_of_samples = min(self.example_count, 3) 189 | 190 | days_with_events = {} 191 | for event in self.calendar_events: 192 | event_start = event.get("DTSTART").dt 193 | if isinstance(event_start, datetime.date) and not isinstance(event_start, datetime.datetime): 194 | event_start = datetime.datetime.combine(event_start, datetime.time(0, tzinfo=self.local_tz)) 195 | if hasattr(event_start, 'astimezone'): 196 | event_start = event_start.astimezone(self.local_tz) 197 | event_day_of_week = event_start.strftime("%A") 198 | event_start_formatted = event_start.strftime('%I:%M %p') 199 | if event_day_of_week not in days_with_events: 200 | days_with_events[event_day_of_week] = [] 201 | event_summary = event.get('SUMMARY') 202 | if not event_summary: 203 | event_summary = event.get('TITLE') 204 | days_with_events[event_day_of_week].append((event_summary, event_start_formatted)) 205 | 206 | days_without_events = [] 207 | days_excluding_today = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] 208 | days_excluding_today.remove(now.strftime("%A")) 209 | for day in days_excluding_today: 210 | if day not in days_with_events: 211 | days_without_events.append(day) 212 | 213 | closest_event = min(self.calendar_events, key=self.get_event_key) 214 | 215 | closest_event_start = closest_event.get("DTSTART").dt 216 | if isinstance(closest_event_start, datetime.date) and not isinstance(closest_event_start, datetime.datetime): 217 | closest_event_start = datetime.datetime.combine(closest_event_start, datetime.time(0, tzinfo=self.local_tz)) 218 | if hasattr(closest_event_start, 'astimezone'): 219 | closest_event_start = closest_event_start.astimezone(self.local_tz) 220 | 221 | closest_event_start_formatted = closest_event_start.strftime('%I:%M %p') 222 | closest_event_day_of_week = closest_event_start.strftime("%A") 223 | 224 | random_event = random.choice(list(days_with_events.values())[0]) 225 | 226 | day_with_event = random.choice(list(days_with_events.keys())) 227 | schedule = ", ".join([f"{summary} at {time}" for summary, time in days_with_events[day_with_event]]) 228 | examples.append( 229 | ( 230 | f"What's my schedule for {day_with_event}?", 231 | f"You have {schedule} on {day_with_event}, {closest_event_start.strftime('%B %-d')}." 232 | ) 233 | ) 234 | 235 | if days_without_events: 236 | day_without_event = random.choice(days_without_events) 237 | examples.append( 238 | ( 239 | f"What's my schedule for {day_without_event}?", 240 | f"Your calendar for {day_without_event} is empty." 241 | ) 242 | ) 243 | 244 | closest_event_summary = closest_event.get('SUMMARY') 245 | if not closest_event_summary: 246 | closest_event_summary = closest_event.get('TITLE') 247 | examples.append( 248 | ( 249 | "What's the first thing in my calendar?", 250 | f"It is {closest_event_summary} at {closest_event_start_formatted} on {closest_event_day_of_week}, {closest_event_start.strftime('%B %d')}" 251 | ) 252 | ) 253 | 254 | examples.append( 255 | ( 256 | f"When was that {random_event[0]} again?", 257 | f"It is at {random_event[1]} on {list(days_with_events.keys())[0]}, {closest_event_start.strftime('%B %d')}" 258 | ) 259 | ) 260 | 261 | examples = random.sample(examples, number_of_samples) 262 | 263 | return { 264 | "prompt": self.llm_prompt, 265 | "examples": examples 266 | } 267 | -------------------------------------------------------------------------------- /plugins/homeassistant.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | import random 4 | 5 | class Adapter: 6 | def __init__(self, config, utils): 7 | self.access_token = config["access_token"] 8 | self.base_url = config["base_url"] 9 | self.ignored_entities = config.get('ignored_entities', []) 10 | self.current_initial_values = None 11 | self.utils = utils 12 | self.areas_enabled = config.get('areas_enabled', False) 13 | self.shopping_list_enabled = config.get('shopping_list_enabled', False) 14 | self.laundry_enabled = config.get('laundry_enabled', False) 15 | self.media_player_enabled = config.get('media_player_enabled', False) 16 | self.person_enabled = config.get('person_enabled', False) 17 | self.color_loop_enabled = config.get('color_loop_enabled', False) 18 | self.music_assistant_enabled = config.get('music_assistant_enabled', False) 19 | self.shopping_list = "" 20 | self.areas_template = """ 21 | {%- for area in areas() %} 22 | { 23 | "area_id": "{{area}}", 24 | "area_name": "{{ area_name(area) }}", 25 | "type": "area", 26 | {%- set ns = namespace() %} 27 | {%- set ns.floor_id = "null" %} 28 | {%- set ns.floor_name = "null" %} 29 | {%- for floor in floors() %} 30 | {%- if area in floor_areas(floor) %} 31 | {%- set ns.floor_id = floor %} 32 | {%- set ns.floor_name = floor_name(floor) %} 33 | {%- endif %} 34 | {%- endfor %} 35 | "floor_id": "{{ns.floor_id}}", 36 | "floor_name": "{{ns.floor_name}}" 37 | }, 38 | {%- endfor %} 39 | """ 40 | self.title_template = """ 41 | {%- set ns = namespace() %} 42 | {%- set ns.floor_id = "null" %} 43 | {%- set ns.floor_name = "null" %} 44 | {%- for floor in floors() %} 45 | {%- if "{{AREA_ID}}" in floor_areas(floor) %} 46 | {%- set ns.floor_id = floor %} 47 | {%- set ns.floor_name = floor_name(floor) %} 48 | {%- endif %} 49 | {%- endfor %} 50 | Devices in area {{AREA_NAME}} (Area ID: {{AREA_ID}} {%- if ns.floor_id != "null" -%}, Floor ID: {{ns.floor_id}} {%- endif -%}): 51 | {%- set ignored_entities = {{IGNORED_ENTITIES}} %} 52 | {%- for device in area_devices('{{AREA_ID}}') %} 53 | {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} 54 | {%- for entity in device_entities(device) %} 55 | {%- set ns = namespace(skip_entity=False) %} 56 | {%- set entity_domain = entity.split('.')[0] %} 57 | {%- if not is_state(entity,'unavailable') and not is_state(entity,'unknown') and not is_state(entity,"None") and not is_hidden_entity(entity) %} 58 | {%- set ns.skip_entity = false %} 59 | {%- for ignored_entity in ignored_entities %} 60 | {%- if ignored_entity in entity|string %} 61 | {%- set ns.skip_entity = true %} 62 | {%- break %} 63 | {%- endif %} 64 | {%- endfor %} 65 | {%- if ns.skip_entity == false %} 66 | 67 | {{ state_attr(entity, 'friendly_name') }} (Entity ID: {{entity}}) 68 | 69 | {%- endif %} 70 | {%- endif %} 71 | {%- endfor %} 72 | {%- endif %} 73 | {%- endfor %} 74 | """ 75 | self.summary_template = """ 76 | {%- set ignored_entities = {{IGNORED_ENTITIES}} %} 77 | {%- for device in area_devices('{{AREA_ID}}') %} 78 | {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} 79 | {%- for entity in device_entities(device) %} 80 | {%- set ns = namespace(skip_entity=False) %} 81 | {%- set entity_domain = entity.split('.')[0] %} 82 | {%- if not is_state(entity,'unavailable') and not is_state(entity,'unknown') and not is_state(entity,"None") and not is_hidden_entity(entity) %} 83 | {%- set ns.skip_entity = false %} 84 | {%- for ignored_entity in ignored_entities %} 85 | {%- if ignored_entity in entity|string %} 86 | {%- set ns.skip_entity = true %} 87 | {%- break %} 88 | {%- endif %} 89 | {%- endfor %} 90 | {%- if ns.skip_entity == false %} 91 | 92 | {%- if entity_domain == "light" and state_attr(entity, 'brightness') %} 93 | 94 | {{ state_attr(entity, 'friendly_name') }} (Entity ID: {{entity}}) is {{ states(entity) }} with a brightness of {{ (state_attr(entity, 'brightness') | float / 255 * 100 ) | int }}% 95 | 96 | {%- else %} 97 | 98 | {{ state_attr(entity, 'friendly_name') }} (Entity ID: {{entity}}) is {{ states(entity) }} 99 | 100 | {%- endif %} 101 | 102 | {%- endif %} 103 | {%- endif %} 104 | {%- endfor %} 105 | {%- endif %} 106 | {%- endfor %} 107 | """ 108 | self.area_lights_template = """ 109 | {% if expand(area_entities(area_name('{{AREA_ID}}')) | select('match', 'light')) 110 | | selectattr('state', 'eq', 'on') | list | count == 0 %} 111 | The {{AREA_NAME}} lights are off. 112 | {% else %} 113 | The {{AREA_NAME}} lights are on. 114 | {% endif %}""" 115 | self.media_player_template = """ 116 | {%- for player in states.media_player %} 117 | {%- if is_state(player.entity_id, 'playing') %} 118 | {{ state_attr(player.entity_id, 'friendly_name') }} (Entity ID: {{player.entity_id}}) is playing {{ state_attr(player.entity_id, 'media_title') }} by {{ state_attr(player.entity_id, 'media_artist') }}. 119 | {%- endif %} 120 | {%- endfor %}""" 121 | self.media_player_title_template = """ 122 | Detect, control and play media content, including songs and playlists, in specific rooms or zones within your smart home, using voice commands such as 'play hotel california in the living room' or 'resume playing music in the kitchen', and get instant access to your favorite media content with voice control. 123 | {%- for player in states.media_player %} 124 | - {{ state_attr(player.entity_id, 'friendly_name') }} (Entity ID: {{player.entity_id}}) 125 | {%- endfor %}""" 126 | 127 | self.mass_media_player_json_template = """ 128 | {%- for area in areas() %} 129 | {%- for device in area_devices(area) %} 130 | {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} 131 | {%- for entity in device_entities(device) %} 132 | {%- set entity_domain = entity.split('.')[0] %} 133 | {%- if not is_state(entity,'unavailable') and not is_state(entity,'unknown') and not is_state(entity,"None") and not is_hidden_entity(entity) %} 134 | {%- if entity_domain == "media_player" and state_attr(entity, 'app_id') %} 135 | { 136 | "entity_id": "{{entity}}", 137 | "entity_name": "{{state_attr(entity, 'friendly_name')}}", 138 | "area_name": "{{ area_name(area) }}", 139 | "area_id": "{{ area }}" 140 | }, 141 | {%- endif %} 142 | {%- endif %} 143 | {%- endfor %} 144 | {%- endif %} 145 | {%- endfor %} 146 | {%- endfor %} 147 | """ 148 | 149 | self.laundry_template = """ 150 | {%- macro time_diff_in_words(timediff) %} 151 | {%- if timediff.total_seconds() < 60 -%} 152 | less than a minute ago 153 | {%- elif timediff.total_seconds() < 7200 -%} 154 | {{ (timediff.total_seconds() // 60) | int }} minutes ago. 155 | {%- elif timediff.total_seconds() < 172800 -%} 156 | {{ (timediff.total_seconds() // 7200) | int }} hours ago. 157 | {%- else -%} 158 | {{ (timediff.total_seconds() // 172800) | int }} days ago. 159 | {%- endif %} 160 | {% endmacro %} 161 | 162 | {% set now = now %} 163 | 164 | {% if is_state("automation.washer_turned_off", "on") -%} 165 | The washer is running. 166 | {%- if states.automation.washer_turned_off.attributes.last_triggered -%} 167 | {% set washer_diff = now() - states.automation.washer_turned_on.attributes.last_triggered %} 168 | It started {{ time_diff_in_words(washer_diff) -}} 169 | {%- endif %} 170 | {% else -%} 171 | The washer is not running. 172 | {%- if states.automation.washer_turned_off.attributes.last_triggered -%} 173 | {% set washer_diff = now() - states.automation.washer_turned_off.attributes.last_triggered %} 174 | It stopped {{ time_diff_in_words(washer_diff) -}} 175 | {% endif %} 176 | {%- endif %} 177 | 178 | {%- if is_state("automation.dryer_turned_off", "on") -%} 179 | The dryer is running. 180 | {%- if states.automation.dryer_turned_off.attributes.last_triggered -%} 181 | {% set dryer_diff = now() - states.automation.dryer_turned_on.attributes.last_triggered %} 182 | It started {{ time_diff_in_words(dryer_diff) -}} 183 | {%- endif -%} 184 | {%- else -%} 185 | The dryer is not running. 186 | {%- if states.automation.dryer_turned_off.attributes.last_triggered -%} 187 | {% set dryer_diff = now() - states.automation.dryer_turned_off.attributes.last_triggered %} 188 | It stopped {{ time_diff_in_words(dryer_diff) -}} 189 | {%- endif %} 190 | {% endif %} 191 | """ 192 | self.person_template = """ 193 | {%- for person in states.person %} 194 | 195 | {{ person.name }} is {% if is_state(person.entity_id, 'home') %}home{% else %}not home{% endif %}. 196 | 197 | {%- endfor %} 198 | """ 199 | self.color_loop_template = """ 200 | {% if is_state("automation.color_loop_bedroom_lamp", "on") or 201 | is_state("automation.color_loop_bedroom_overhead", "on") -%} 202 | Color loop (unicorn vomit) in the bedroom is enabled. Run service named script.disable_color_loop_bedroom to disable. 203 | {%- else -%} 204 | Color loop (unicorn vomit) in the bedroom is disabled. Run service named script.enable_color_loop_bedroom to enable. 205 | {%- endif %} 206 | 207 | {% if is_state("automation.color_loop_office_overhead_left", "on") or 208 | is_state("automation.color_loop_office_overhead_right", "on") -%} 209 | Color loop (unicorn vomit) in the office is enabled. Run service named script.disable_color_loop_office to disable. 210 | {%- else -%} 211 | Color loop (unicorn vomit) in the office is disabled. Run service named script.enable_color_loop_office to enable. 212 | {%- endif %} 213 | 214 | {% if is_state("automation.color_loop_living_room_couch_overhead", "on") 215 | or is_state("automation.color_loop_living_room_table_overhead", "on") or 216 | is_state("automation.color_loop_living_room_lamp_upper", "on") or 217 | is_state("automation.color_loop_living_room_big_couch_overhead", "on") or 218 | is_state("automation.color_loop_living_room_lamp_side", "on") -%} 219 | Color loop (unicorn vomit) in the living room is enabled. Run service named script.enable_color_loop_living_room to disable. 220 | {%- else -%} 221 | Color loop (unicorn vomit) in the living room is disabled. Run service named script.enable_color_loop_living_room to enable. 222 | {%- endif %} 223 | 224 | {% if is_state("automation.color_loop_music_room_lamp_side", "on") or 225 | is_state("automation.color_loop_music_room_lamp_top", "on") or 226 | is_state("automation.color_loop_music_room_light_strip", "on") -%} 227 | Color loop (unicorn vomit) in the music room is enabled. Run service named script.enable_color_loop_music_room to disable. 228 | {%- else -%} 229 | Color loop (unicorn vomit) in the music room is disabled. Run service named script.enable_color_loop_music_room to enable. 230 | {%- endif %} 231 | 232 | {% if is_state("automation.party_mode_living_room_couch_overhead", "on") 233 | or is_state("automation.party_mode_living_room_table_overhead", "on") or 234 | is_state("automation.party_mode_living_room_lamp_upper", "on") or 235 | is_state("automation.party_mode_living_room_big_couch_overhead", "on") or 236 | is_state("automation.party_mode_living_room_lamp_side", "on") or 237 | is_state("automation.party_mode_music_room_lamp_side", "on") or 238 | is_state("automation.party_mode_music_room_lamp_top", "on") or 239 | is_state("automation.party_mode_music_room_light_strip", "on") -%} 240 | Party mode is enabled. Run service named script.disable_party_mode to disable. 241 | {%- else -%} 242 | Party mode is disabled. Run service named script.enable_party_mode to enable. 243 | {%- endif %} 244 | """ 245 | 246 | def update(self): 247 | current_initial_values = [] 248 | if self.areas_enabled: 249 | areas = self.get_areas() 250 | for area in areas: 251 | title_template_edited = self.title_template.replace('{{AREA_NAME}}', area['area_name']) 252 | title_template_edited = title_template_edited.replace('{{AREA_ID}}', area['area_id']) 253 | title_template_edited = title_template_edited.replace('{{IGNORED_ENTITIES}}', json.dumps(self.ignored_entities)) 254 | title = requests.post(f'{self.base_url}/api/template', 255 | json={"template": title_template_edited}, 256 | headers={"Authorization": f"Bearer {self.access_token}"}, 257 | timeout=10).text 258 | area['title'] = title 259 | # see if we have any summary information at all 260 | # if we don't, do not include the area in the initial values 261 | if len(area['title'].split('\n')) > 1: 262 | area['embedding'] = self.utils['get_embedding'](title) 263 | current_initial_values.append(area) 264 | 265 | if self.shopping_list_enabled: 266 | self.shopping_list = self.get_shopping_list() 267 | shopping_list_text = 'Shopping list for the entire household:\n' 268 | for shopping_list_item in self.shopping_list: 269 | shopping_list_text = shopping_list_text + f"- {shopping_list_item['name']}\n" 270 | current_initial_values.append({ 271 | "type": "shopping_list", 272 | "title": shopping_list_text, 273 | "embedding": self.utils['get_embedding'](shopping_list_text) 274 | }) 275 | 276 | if self.laundry_enabled: 277 | laundry_title = 'States of laundry appliances (washer and dryer)' 278 | current_initial_values.append({ 279 | "type": "laundry", 280 | "title": laundry_title, 281 | "embedding": self.utils['get_embedding'](laundry_title) 282 | }) 283 | 284 | if self.media_player_enabled: 285 | summary = requests.post(f'{self.base_url}/api/template', 286 | json={"template": self.media_player_title_template}, 287 | headers={"Authorization": f"Bearer {self.access_token}"}, 288 | timeout=10).text 289 | if summary.strip(): 290 | current_initial_values.append({ 291 | "type": "media_player", 292 | "title": summary.strip(), 293 | "embedding": self.utils['get_embedding'](summary.strip()) 294 | }) 295 | 296 | if self.person_enabled: 297 | person_title = 'All people in HomeAssistant and whether if any of them are home' 298 | current_initial_values.append({ 299 | "type": "person", 300 | "title": person_title, 301 | "embedding": self.utils['get_embedding'](person_title) 302 | }) 303 | 304 | if self.color_loop_enabled: 305 | color_loop_title = 'The status of color loop (unicorn vomit mode) and party modes across the house' 306 | current_initial_values.append({ 307 | "type": "color_loop", 308 | "title": color_loop_title, 309 | "embedding": self.utils['get_embedding'](color_loop_title) 310 | }) 311 | 312 | self.current_initial_values = current_initial_values 313 | 314 | def get_areas(self): 315 | areas_response = requests.post(f'{self.base_url}/api/template', 316 | json={"template": self.areas_template}, 317 | headers={"Authorization": f"Bearer {self.access_token}"}, 318 | timeout=10) 319 | 320 | # create a JSON from the result 321 | # remove the trailing comma so the parsing wont fail 322 | areas_json = f'[{areas_response.text[:-1]}]' 323 | areas = json.loads(areas_json) 324 | # make all area names lowercase 325 | # this will help the LLM understand as different capitalization can sometimes be tokenized differently 326 | for area in areas: 327 | area['area_name'] = area['area_name'].lower() 328 | return areas 329 | 330 | def get_music_assistant_entities(self): 331 | music_assistant_entities_response = requests.post(f'{self.base_url}/api/template', 332 | json={"template": self.mass_media_player_json_template}, 333 | headers={"Authorization": f"Bearer {self.access_token}"}, 334 | timeout=10) 335 | 336 | # create a JSON from the result 337 | # remove the trailing comma so the parsing wont fail 338 | music_assistant_entities_json = f'[{music_assistant_entities_response.text[:-1]}]' 339 | music_assistant_entities = json.loads(music_assistant_entities_json) 340 | # make all music player names lowercase 341 | # this will help the LLM understand as different capitalization can sometimes be tokenized differently 342 | for entity in music_assistant_entities: 343 | entity['entity_name'] = entity['entity_name'].lower() 344 | return music_assistant_entities 345 | 346 | def get_shopping_list(self): 347 | shopping_list_response = requests.get(f'{self.base_url}/api/shopping_list', 348 | headers={"Authorization": f"Bearer {self.access_token}"}, 349 | timeout=10) 350 | 351 | shopping_list = shopping_list_response.json() 352 | return shopping_list 353 | 354 | def get_documents(self): 355 | return self.current_initial_values 356 | 357 | def get_llm_prompt_addition(self, document, user_prompt): 358 | examples = [] 359 | llm_prompt = "" 360 | match document['type']: 361 | case "shopping_list": 362 | if self.shopping_list: 363 | llm_prompt = llm_prompt + 'Shopping list contents:\n' 364 | for shopping_list_item in self.shopping_list: 365 | llm_prompt = llm_prompt + f"- {shopping_list_item['name']}\n" 366 | llm_prompt = llm_prompt + '\n Do not add anything to the shopping list if it is already there!' 367 | else: 368 | llm_prompt = "The shopping list is currently empty." 369 | examples.append( 370 | ( 371 | 'Add eggs to the shopping list.', 372 | 'Eggs were successfully added to the shopping list. $ActionRequired {"service": "shopping_list.add_item", "name": "eggs"}"' 373 | ) 374 | ) 375 | sample_shopping_list_item = 'chicken' 376 | if self.shopping_list: 377 | sample_shopping_list_item = random.choice(self.shopping_list)['name'] 378 | examples.append( 379 | ( 380 | 'Remove ' + sample_shopping_list_item + ' from the shopping list.', 381 | sample_shopping_list_item + ' was removed from the shopping list. $ActionRequired {"service": "shopping_list.remove_item", "name": "' + sample_shopping_list_item + '"}' 382 | ) 383 | ) 384 | examples.append( 385 | ( 386 | 'Add ' + sample_shopping_list_item + ' to the shopping list.', 387 | sample_shopping_list_item + ' is already in the shopping list! $NoActionRequired' 388 | ) 389 | ) 390 | case "area": 391 | summary_template_edited = self.summary_template.replace('{{AREA_NAME}}', document['area_name']) 392 | summary_template_edited = summary_template_edited.replace('{{AREA_ID}}', document['area_id']) 393 | summary_template_edited = summary_template_edited.replace('{{IGNORED_ENTITIES}}', json.dumps(self.ignored_entities)) 394 | summary = requests.post(f'{self.base_url}/api/template', 395 | json={"template": summary_template_edited}, 396 | headers={"Authorization": f"Bearer {self.access_token}"}, 397 | timeout=10).text 398 | if document['floor_id'] and document['floor_name']: 399 | llm_prompt = llm_prompt + f""" 400 | {document['area_name']} (Area ID: {document['area_id']}, located {document['floor_name']}, Floor ID: {document['floor_id']}): 401 | 402 | {summary} 403 | 404 | """ 405 | examples.append( 406 | ( 407 | f'Turn on all lights {document["floor_name"]}.', 408 | 'The lights ' + document["floor_name"] + ' are now on. $ActionRequired {"service": "light.turn_on", "floor_id": "' + document['floor_id'] + '"}' 409 | ) 410 | ) 411 | else: 412 | llm_prompt = llm_prompt + f""" 413 | {document['area_name']} (Area ID: {document['area_id']}): 414 | 415 | {summary} 416 | 417 | """ 418 | examples.append( 419 | ( 420 | f'Brighten the {document["area_name"]} lights.', 421 | 'The ' + document["area_name"] + ' lights are set to 100% brightness. $ActionRequired {"service": "light.turn_on", "brightness_pct": 100, "area_id": "' + document['area_id'] + '"}' 422 | ) 423 | ) 424 | 425 | area_lights_template_edited = self.area_lights_template.replace('{{AREA_NAME}}', document['area_name']) 426 | area_lights_template_edited = area_lights_template_edited.replace('{{AREA_ID}}', document['area_id']) 427 | area_lights_status = requests.post(f'{self.base_url}/api/template', 428 | json={"template": area_lights_template_edited}, 429 | headers={"Authorization": f"Bearer {self.access_token}"}, 430 | timeout=10).text 431 | examples.append( 432 | ( 433 | f'Are the {document["area_name"]} lights on?', 434 | area_lights_status + ' $NoActionRequired' 435 | ) 436 | ) 437 | 438 | examples.append( 439 | ( 440 | f'Turn off the {document["area_name"]} lights.', 441 | 'The ' + document["area_name"] + ' lights are now off. $ActionRequired {"service": "light.turn_off", "area_id": "' + document['area_id'] + '"}' 442 | ) 443 | ) 444 | case "laundry": 445 | summary = requests.post(f'{self.base_url}/api/template', 446 | json={"template": self.laundry_template}, 447 | headers={"Authorization": f"Bearer {self.access_token}"}, 448 | timeout=10).text 449 | 450 | llm_prompt = llm_prompt + f""" 451 | 452 | {summary} 453 | 454 | """ 455 | case "media_player": 456 | summary = requests.post(f'{self.base_url}/api/template', 457 | json={"template": self.media_player_template}, 458 | headers={"Authorization": f"Bearer {self.access_token}"}, 459 | timeout=10).text 460 | if not summary.strip(): 461 | summary = "No media is playing in the household right now." 462 | 463 | llm_prompt = llm_prompt + f""" 464 | 465 | {summary} 466 | 467 | """ 468 | if self.music_assistant_enabled: 469 | music_assistant_entities = self.get_music_assistant_entities() 470 | if music_assistant_entities: 471 | sample_entity = random.choice(music_assistant_entities) 472 | sample_entity_name = sample_entity['entity_name'] 473 | sample_entity_id = sample_entity['entity_id'] 474 | sample_entity_area_id = sample_entity['area_id'] 475 | sample_entity_area_name = sample_entity['area_name'] 476 | # Music Assistant seems to play on whatever devices we've chosen previously - so no need to define entity or area names yet 477 | examples.append( 478 | ( 479 | f'Play Hotel California by The Eagles', 480 | f'Now playing Hotel California by The Eagles. $ActionRequired ' + '{"service": "mass.play_media", "media_id": "Hotel California Eagles", "entity_id": "' + sample_entity_id + '"}' 481 | ) 482 | ) 483 | case "person": 484 | summary = requests.post(f'{self.base_url}/api/template', 485 | json={"template": self.person_template}, 486 | headers={"Authorization": f"Bearer {self.access_token}"}, 487 | timeout=10).text 488 | 489 | llm_prompt = llm_prompt + f""" 490 | 491 | {summary} 492 | 493 | """ 494 | case "color_loop": 495 | summary = requests.post(f'{self.base_url}/api/template', 496 | json={"template": self.color_loop_template}, 497 | headers={"Authorization": f"Bearer {self.access_token}"}, 498 | timeout=10).text 499 | 500 | llm_prompt = llm_prompt + f""" 501 | 502 | {summary} 503 | 504 | """ 505 | return { 506 | "prompt": llm_prompt, 507 | "examples": examples 508 | } 509 | -------------------------------------------------------------------------------- /plugins/weather.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from env_canada import ECWeather 3 | 4 | class Adapter: 5 | def __init__(self, config, utils): 6 | if 'station_id' in config: 7 | self.ECWeather = ECWeather(station_id=config["station_id"]) 8 | elif 'coordinates' in config: 9 | self.ECWeather = ECWeather(coordinates=config["coordinates"]) 10 | else: 11 | raise Exception("station_id or coordinates must be defined in the configuration!") 12 | self.utils = utils 13 | self.values_to_use = [ 14 | "temperature", 15 | "wind_chill", 16 | "humidex", 17 | "humidity", 18 | "condition", 19 | "wind_speed", 20 | "wind_gust", 21 | "high_temp", 22 | "low_temp", 23 | "pop", 24 | "text_summary", 25 | "Forecast" 26 | ] 27 | 28 | def update(self): 29 | asyncio.run(self.ECWeather.update()) 30 | 31 | def get_documents(self): 32 | # for now, let's only give one category for the weather 33 | title = "The current weather conditions and weather forecast for the next week." 34 | return [ 35 | { 36 | "title": title, 37 | "embedding": self.utils['get_embedding'](title) 38 | } 39 | ] 40 | 41 | def augment_summary(self, data): 42 | summary = [] 43 | text_summary = "" 44 | for key, data in data.items(): 45 | label = data["label"] 46 | value = self.format_value(data.get("value"), data.get("unit")) 47 | if key in self.values_to_use: 48 | if value: 49 | if key == "text_summary": 50 | text_summary = value 51 | continue 52 | summary.append(f"{label}: {value}") 53 | 54 | if 'text_summary' in data: 55 | summary.append(data["text_summary"]["value"]) 56 | if 'Forecast' in data: 57 | summary.append(data["text_summary"]["value"]) 58 | 59 | augmented_summary = ( 60 | text_summary + ' ' + 61 | ", ".join([s for s in summary if "Temperature" in s or "Condition" in s or "Wind" in s]) + ". " 62 | ) 63 | 64 | return augmented_summary 65 | 66 | def get_llm_prompt_addition(self, selected_categories, user_prompt): 67 | llm_prompt = "" 68 | # we don't need examples as the weather tends to be fairly self-explanatory 69 | examples = [] 70 | 71 | llm_prompt = "Current weather conditions: " + self.augment_summary(self.ECWeather.conditions) 72 | for forecast in self.ECWeather.daily_forecasts: 73 | summary = f"{forecast['text_summary']} Expected temperature: {forecast['temperature']}" 74 | llm_prompt = llm_prompt + f"\nWeather forecast for {forecast['period']}: {summary}" 75 | return { 76 | "prompt": llm_prompt, 77 | "examples": examples 78 | } 79 | 80 | def format_value(self, value, unit=None): 81 | if value is None: 82 | return "" 83 | elif unit: 84 | return f"{value} {unit}" 85 | else: 86 | return str(value) 87 | 88 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | env-canada 3 | tzlocal 4 | icalendar 5 | python-dateutil 6 | uvicorn 7 | fastapi 8 | numpy --------------------------------------------------------------------------------