From 5a39d25e4fabdf40ef05f2d5493a1aa48b5a7986 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 5 Sep 2026 20:19:20 +0300 Subject: [PATCH] Remove Assist tools from PowerLLM API --- .github/workflows/powerllm.yml | 2 +- README.md | 23 +- custom_components/powerllm/__init__.py | 19 +- custom_components/powerllm/api.py | 274 +----- custom_components/powerllm/config_flow.py | 19 +- custom_components/powerllm/llm_tools.py | 351 +------- custom_components/powerllm/strings.json | 20 +- custom_components/powerllm/tools/get_state.py | 317 +++++++ custom_components/powerllm/tools/script.py | 21 +- .../powerllm/translations/en.json | 20 +- hacs.json | 2 +- requirements_test_all.txt | 1 + setup.cfg | 4 +- setup.py | 2 +- tests/conftest.py | 2 +- tests/const.py | 10 - tests/snapshots/test_http.ambr | 156 ---- tests/test_api.py | 817 +++--------------- tests/test_config_flow.py | 41 +- tests/test_http.py | 199 ++--- tests/test_llm_tools.py | 36 +- tests/tools/test_get_state.py | 239 +++++ tests/tools/test_python_code.py | 4 - tests/tools/test_script.py | 38 + tests/tools/test_web_scrape.py | 4 - 25 files changed, 925 insertions(+), 1696 deletions(-) create mode 100644 custom_components/powerllm/tools/get_state.py delete mode 100644 tests/snapshots/test_http.ambr create mode 100644 tests/tools/test_get_state.py create mode 100644 tests/tools/test_script.py diff --git a/.github/workflows/powerllm.yml b/.github/workflows/powerllm.yml index 806d221..9403e37 100644 --- a/.github/workflows/powerllm.yml +++ b/.github/workflows/powerllm.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.13', '3.14'] + python-version: ['3.14'] steps: - uses: awalsh128/cache-apt-pkgs-action@latest diff --git a/README.md b/README.md index a478276..95ae924 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ This integration provides: 1. HTTP API for available LLM tools to integrate HA LLM Tools with an externally running LLM 2. Framework to easily add new LLM tools from other custom integrations, making Home Assistant a platform for LLM tools experimentation. -3. Enhanced and experimental versions of core 'Assist' LLM tools -4. Selectively Enable/Disable any tool +3. Detailed entity queries with `HassGetState`, including attributes and time since the last state change +4. Selectively enable or disable PowerLLM tools 5. Extra LLM tools: * Web and news search with Duck Duck Go * Web scrapping to access the Internet @@ -20,22 +20,23 @@ Please feel free to raise an issue if you have an idea of another useful tool! ## Installation +Requires Home Assistant 2026.8 or later. + 1. Copy `custom_components/powerllm` directory from this repository into `custom_components/` directory in your config directory. Optionally use HACS for this step. 2. Restart Home Assistant 3. Add a config entry by going to Settings -> Device and Services -> Add integration or by pressing here: [![Add integration to My HA](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=powerllm). If you only need the HTTP API but not the extra tools, then you can just add `powerllm:` into your `configuration.yaml` instead. -4. Configure your LLM integrations, such as [OpenAI Conversation](https://www.home-assistant.io/integrations/openai_conversation/) [![Show integration on My HA](https://my.home-assistant.io/badges/integration.svg)](https://my.home-assistant.io/redirect/integration/?domain=openai_conversation) or [Google Generative AI](https://www.home-assistant.io/integrations/google_generative_ai_conversation/) [![Show integration on My HA](https://my.home-assistant.io/badges/integration.svg)](https://my.home-assistant.io/redirect/integration/?domain=google_generative_ai_conversation) to use Power LLM API instead of Assist API. +4. Configure your LLM integrations, such as [OpenAI Conversation](https://www.home-assistant.io/integrations/openai_conversation/) [![Show integration on My HA](https://my.home-assistant.io/badges/integration.svg)](https://my.home-assistant.io/redirect/integration/?domain=openai_conversation) or [Google Generative AI](https://www.home-assistant.io/integrations/google_generative_ai_conversation/) [![Show integration on My HA](https://my.home-assistant.io/badges/integration.svg)](https://my.home-assistant.io/redirect/integration/?domain=google_generative_ai_conversation) to select **both Assist and your Power LLM API**. Assist provides home control, timers, exposed scripts, calendars, to-do lists, live context, and date/time. PowerLLM provides the additional tools. + +When upgrading a pre-HA2026.8 installation, add Assist to every conversation agent that previously selected only PowerLLM. PowerLLM no longer duplicates Assist tools or automatically exposes registered intents. Integrations should contribute those tools through Home Assistant's `llm` platform. PowerLLM's custom-tool registration framework remains available. ## Configuration There are following configuration options available: -* ### Include exposed entities into api prompt - For each interaction with LLM, a system prompt is generated. If this option is enabled, the system prompt will contain the list of all exposed devices. It would allow the LLM to find the devices you refer to more fast and reliable, but also consume input tokens. If your list of exposed entities is really big, you may want to disable this option and rely on other methods, such as explicit querying (see next option) or just guessing by its name from the user prompt (make sure to set up your aliases). - -* ### Include relevant entities into intent tool response - Some intents will return the states of affected entities in its response. If this option is enabled, they are also forwarded to the LLM. Usually they are not so important, and a simple response will also do the job. With one exception: the `HassGetState`, this is the intent specifically used to find entities matching certain criteria and return their states in the response. In fact, if this option is disabled, the `HassGetState` would not be exposed to the LLM at all. +* ### Tool selection + Select which PowerLLM tools the model may call. `HassGetState` is a normal selectable tool that always returns detailed states for matching entities, including entity IDs, attributes, aliases, area/floor, and time since the last state change. It can query both entities matching a requested state and those that do not match. - Please keep in mind that the state returned returned using this option contains more information and attributes, than the list in the prompt. So if you want your LLM to be able to answer questions like `how long have the lights been on?`, keep it enabled. + Assist controls its own tools and entity overview. The former options to include exposed entities in the prompt and attach entity states to every intent response have been removed. An upgrade preserves a previously disabled `HassGetState` in tool selection. Detailed state queries remain available as a separate tool call; Assist's control responses are unchanged. * ### DuckDuckGo Region The server location used for web and news search. You can safely leave it as `No Region`. @@ -43,11 +44,15 @@ There are following configuration options available: * ### Only allow referencing exposed entities in scripts Power LLM includes a tool that allows LLM to write scripts in Home Assistant format and instantly execute them to handle more complex tasks than covered by standard intents. If this option is enabled, Power LLM will make an effort to verify that all entities referenced in this script are exposed. This process however has certain limitations (for example if the entity id is evaluated from template at runtime), so the script might fail this check more often than wanted. + When the script tool is enabled, its prompt includes a mapping of exposed entity IDs to names, with no states or other attributes. Disabling the tool removes this mapping. The mapping always includes only exposed entities, even when the script exposure restriction is disabled. + * ### Facts that the model remembers for each user These field contain the facts that the LLM chose to remember about the user for each `user_id`. You can also ask LLM to remember something about you. This option is presented here in case you want to delete something. ## HTTP API +HTTP clients that previously fetched all tools from `/api/powerllm/powerllm` must also fetch `/api/powerllm/assist` and send each tool call to its owning API. These endpoints expose one API at a time. Conversation integrations selecting both APIs use Home Assistant's merged API, which prefixes tool names with their API namespace. + This is an extension of [Home Assistant REST API](https://developers.home-assistant.io/docs/api/rest/) providing LLM-specific endpoints, such as: * GET `/api/powerllm` diff --git a/custom_components/powerllm/__init__.py b/custom_components/powerllm/__init__.py index 584c184..5c8c4bd 100644 --- a/custom_components/powerllm/__init__.py +++ b/custom_components/powerllm/__init__.py @@ -8,7 +8,13 @@ from homeassistant.helpers.typing import ConfigType from .api import PowerLLMAPI -from .const import CONF_MEMORY_PROMPTS, DOMAIN +from .const import ( + CONF_INTENT_ENTITIES, + CONF_MEMORY_PROMPTS, + CONF_PROMPT_ENTITIES, + CONF_TOOL_SELECTION, + DOMAIN, +) from .http import LLMToolsApiView, LLMToolsListView, LLMToolView from .llm_tools import ( # noqa: F401 PowerLLMTool as PowerLLMTool, @@ -61,6 +67,17 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, options=new_options, minor_version=2 ) + if entry.version == 1 and entry.minor_version < 3: + new_options = dict(entry.options) + new_options.pop(CONF_PROMPT_ENTITIES, None) + if not new_options.pop(CONF_INTENT_ENTITIES, True): + selection = dict(new_options.get(CONF_TOOL_SELECTION, {})) + selection["HassGetState"] = False + new_options[CONF_TOOL_SELECTION] = selection + hass.config_entries.async_update_entry( + entry, options=new_options, minor_version=3 + ) + _LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/custom_components/powerllm/api.py b/custom_components/powerllm/api.py index c3f58c4..f437c68 100644 --- a/custom_components/powerllm/api.py +++ b/custom_components/powerllm/api.py @@ -1,299 +1,67 @@ -"""Power LLM Tools.""" +"""Power LLM API for tools that complement Assist.""" from __future__ import annotations -import logging -from functools import cache, partial - import slugify as unicode_slug -from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN -from homeassistant.components.cover.intent import INTENT_CLOSE_COVER, INTENT_OPEN_COVER -from homeassistant.components.intent import async_device_supports_timers -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN -from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN -from homeassistant.components.todo import DOMAIN as TODO_DOMAIN -from homeassistant.components.weather import INTENT_GET_WEATHER from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEFAULT, CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - area_registry as ar, - device_registry as dr, - floor_registry as fr, - intent, - llm, -) -from homeassistant.util import yaml +from homeassistant.helpers import llm from .const import ( CONF_DUCKDUCKGO_REGION, - CONF_INTENT_ENTITIES, - CONF_PROMPT_ENTITIES, CONF_SCRIPT_EXPOSED_ONLY, CONF_TOOL_SELECTION, DOMAIN, ) -from .llm_tools import ( - PowerCalendarGetEventsTool, - PowerGetDateTimeTool, - PowerGetLiveContextTool, - PowerIntentTool, - PowerLLMTool, - PowerScriptTool, - PowerTodoGetItemsTool, -) +from .llm_tools import PowerLLMTool from .tools.duckduckgo import DDGNewsTool, DDGTextSearchTool +from .tools.get_state import GetStateTool from .tools.memory import MemoryTool from .tools.script import DynamicScriptTool -_LOGGER = logging.getLogger(__name__) - class PowerLLMAPI(llm.API): """API exposing PowerLLM tools to LLMs.""" - IGNORE_INTENTS = { - INTENT_GET_WEATHER, - INTENT_OPEN_COVER, # deprecated - INTENT_CLOSE_COVER, # deprecated - intent.INTENT_NEVERMIND, - intent.INTENT_TOGGLE, - intent.INTENT_GET_CURRENT_DATE, - intent.INTENT_GET_CURRENT_TIME, - intent.INTENT_GET_TEMPERATURE, - intent.INTENT_RESPOND, - } - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Init the class.""" + """Initialize the API.""" super().__init__( hass=hass, id=unicode_slug.slugify(config_entry.data[CONF_NAME], separator="_"), name=config_entry.data[CONF_NAME], ) - self.cached_slugify = cache( - partial(unicode_slug.slugify, separator="_", lowercase=False) - ) self.config_entry = config_entry async def async_get_api_instance( self, llm_context: llm.LLMContext ) -> llm.APIInstance: - """Return the instance of the API.""" - if llm_context.assistant: - exposed_entities: dict | None = llm._get_exposed_entities( - self.hass, llm_context.assistant, include_state=False - ) - else: - exposed_entities = None - - tools = self._async_get_tools(llm_context, exposed_entities) - api_prompt = self._async_get_api_prompt(llm_context, exposed_entities, tools) - + """Return the tools and prompts applicable to this request.""" + tools = self._async_get_tools(llm_context) + prompts = [ + prompt + for tool in tools + if (prompt := tool.prompt(self.hass, llm_context)) is not None + ] return llm.APIInstance( api=self, - api_prompt=api_prompt, + api_prompt="\n".join(prompts), llm_context=llm_context, tools=tools, custom_serializer=llm.selector_serializer, ) @callback - def _async_get_api_prompt( - self, - llm_context: llm.LLMContext, - exposed_entities: dict | None, - tools: list[PowerLLMTool], - ) -> str: - """Return the prompt for the API.""" - if not exposed_entities or not exposed_entities["entities"]: - return llm.NO_ENTITIES_PROMPT - return "\n".join( - [ - *self._async_get_preable(llm_context), - *self._async_get_exposed_entities_prompt(llm_context, exposed_entities), - *self._async_get_tools_prompt(llm_context, tools), - ] - ) - - @callback - def _async_get_preable(self, llm_context: llm.LLMContext) -> list[str]: - """Return the prompt for the API.""" - - prompt = [ - ( - "When controlling Home Assistant always call the intent tools. " - "When controlling a device, prefer passing just name and domain. " - "When controlling an area, prefer passing just area name and domain." - ) - ] - area: ar.AreaEntry | None = None - floor: fr.FloorEntry | None = None - if llm_context.device_id: - device_reg = dr.async_get(self.hass) - device = device_reg.async_get(llm_context.device_id) - - if device: - area_reg = ar.async_get(self.hass) - if device.area_id and (area := area_reg.async_get_area(device.area_id)): - floor_reg = fr.async_get(self.hass) - if area.floor_id: - floor = floor_reg.async_get_floor(area.floor_id) - - extra = ( - "and all generic commands like 'turn on the lights' " - "should target this area." - ) - - if floor and area: - prompt.append(f"You are in area {area.name} (floor {floor.name}) {extra}") - elif area: - prompt.append(f"You are in area {area.name} {extra}") - else: - prompt.append( - "When a user asks to turn on all devices of a specific type, " - "ask user to specify an area, unless there is only one device " - "of that type." - ) - - if not llm_context.device_id or not async_device_supports_timers( - self.hass, llm_context.device_id - ): - prompt.append("This device is not able to start timers.") - - if self.config_entry.options[CONF_PROMPT_ENTITIES]: - prompt.append(llm.DYNAMIC_CONTEXT_PROMPT) - - return prompt - - @callback - def _async_get_exposed_entities_prompt( - self, llm_context: llm.LLMContext, exposed_entities: dict | None - ) -> list[str]: - """Return the prompt for the API for exposed entities.""" - prompt = [] - - if ( - self.config_entry.options[CONF_PROMPT_ENTITIES] - and exposed_entities - and exposed_entities["entities"] - ): - if any( - entity.get("domain") == LOCK_DOMAIN - for entity in exposed_entities["entities"].values() - ): - prompt.append( - "Use HassTurnOn to lock and HassTurnOff to unlock a lock." - ) - - prompt.append( - "Static Context: An overview of the areas and the devices in this " - "smart home:" - ) - prompt.append(yaml.dump(exposed_entities["entities"])) - - return prompt - - @callback - def _async_get_tools_prompt( - self, llm_context: llm.LLMContext, tools: list[PowerLLMTool] - ) -> list[str]: - """Return the prompt for the API for available tools.""" - prompt = [] - - for tool in tools: - if (tool_prompt := tool.prompt(self.hass, llm_context)) is not None: - prompt.append(tool_prompt) - - return prompt - - @callback - def _async_get_tools( - self, llm_context: llm.LLMContext, exposed_entities: dict | None - ) -> list[PowerLLMTool]: - """Return a list of LLM tools.""" - ignore_intents = self.IGNORE_INTENTS - if not llm_context.device_id or not async_device_supports_timers( - self.hass, llm_context.device_id - ): - ignore_intents = ignore_intents | { - intent.INTENT_START_TIMER, - intent.INTENT_CANCEL_TIMER, - intent.INTENT_INCREASE_TIMER, - intent.INTENT_DECREASE_TIMER, - intent.INTENT_PAUSE_TIMER, - intent.INTENT_UNPAUSE_TIMER, - intent.INTENT_TIMER_STATUS, - } - - if not self.config_entry.options[CONF_INTENT_ENTITIES]: - ignore_intents = ignore_intents | {intent.INTENT_GET_STATE} - - intent_handlers = [ - intent_handler - for intent_handler in intent.async_get(self.hass) - if intent_handler.intent_type not in ignore_intents - ] - - exposed_domains: set[str] | None = None - if exposed_entities is not None: - exposed_domains = { - info["domain"] for info in exposed_entities["entities"].values() - } - - intent_handlers = [ - intent_handler - for intent_handler in intent_handlers - if intent_handler.platforms is None - or intent_handler.platforms & exposed_domains - ] - + def _async_get_tools(self, llm_context: llm.LLMContext) -> list[PowerLLMTool]: + """Return enabled tools applicable to the request.""" tools: list[PowerLLMTool] = [ - PowerIntentTool( - self.cached_slugify(intent_handler.intent_type), - intent_handler, - self.config_entry.options[CONF_INTENT_ENTITIES], - ) - for intent_handler in intent_handlers + GetStateTool(), + DynamicScriptTool(self.config_entry.options[CONF_SCRIPT_EXPOSED_ONLY]), + DDGTextSearchTool(self.config_entry.options[CONF_DUCKDUCKGO_REGION]), + DDGNewsTool(self.config_entry.options[CONF_DUCKDUCKGO_REGION]), + MemoryTool(self.config_entry), + *self.hass.data.get(DOMAIN, {}).values(), ] - - tools.append(PowerGetDateTimeTool()) - - if exposed_entities: - if exposed_entities[CALENDAR_DOMAIN]: - names = [] - for info in exposed_entities[CALENDAR_DOMAIN].values(): - names.extend(info["names"].split(", ")) - tools.append(PowerCalendarGetEventsTool(names)) - - if exposed_domains is not None and TODO_DOMAIN in exposed_domains: - names = [] - for info in exposed_entities["entities"].values(): - if info["domain"] != TODO_DOMAIN: - continue - names.extend(info["names"].split(", ")) - tools.append(PowerTodoGetItemsTool(names)) - - tools.extend( - PowerScriptTool(self.hass, script_entity_id) - for script_entity_id in exposed_entities[SCRIPT_DOMAIN] - ) - - if exposed_domains: - tools.append(PowerGetLiveContextTool()) - - tools.append( - DynamicScriptTool(self.config_entry.options[CONF_SCRIPT_EXPOSED_ONLY]) - ) - - tools.append( - DDGTextSearchTool(self.config_entry.options[CONF_DUCKDUCKGO_REGION]) - ) - tools.append(DDGNewsTool(self.config_entry.options[CONF_DUCKDUCKGO_REGION])) - tools.append(MemoryTool(self.config_entry)) - - tools.extend(self.hass.data.get(DOMAIN, {}).values()) - tool_selection = self.config_entry.options.get(CONF_TOOL_SELECTION, {}) tool_selection_default = tool_selection.get(CONF_DEFAULT, True) return [ diff --git a/custom_components/powerllm/config_flow.py b/custom_components/powerllm/config_flow.py index 3c21963..41ddaaf 100644 --- a/custom_components/powerllm/config_flow.py +++ b/custom_components/powerllm/config_flow.py @@ -6,6 +6,7 @@ from typing import Any import voluptuous as vol +from homeassistant.components.conversation import DOMAIN as CONVERSATION_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEFAULT, CONF_NAME from homeassistant.core import Context @@ -15,9 +16,7 @@ from .api import PowerLLMAPI from .const import ( CONF_DUCKDUCKGO_REGION, - CONF_INTENT_ENTITIES, CONF_MEMORY_PROMPTS, - CONF_PROMPT_ENTITIES, CONF_SCRIPT_EXPOSED_ONLY, CONF_TOOL_SELECTION, DOMAIN, @@ -109,7 +108,7 @@ class PowerLLMFlow(RecursiveConfigFlow, domain=DOMAIN, data_schema=DATA_SCHEMA): """Handle config and options flow for Power LLM.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_validate_input( self, step_id: str, user_input: dict[str, Any] @@ -138,8 +137,6 @@ async def get_options_schema(self) -> vol.Schema: subentries_data=None, data={CONF_NAME: "Temp"}, options={ - CONF_PROMPT_ENTITIES: False, - CONF_INTENT_ENTITIES: True, CONF_DUCKDUCKGO_REGION: "wt-wt", CONF_SCRIPT_EXPOSED_ONLY: False, }, @@ -148,13 +145,13 @@ async def get_options_schema(self) -> vol.Schema: platform=DOMAIN, context=Context(user_id="Temp"), language=None, - assistant=None, + assistant=CONVERSATION_DOMAIN, device_id=None, ) - tmp_api = await PowerLLMAPI(self.hass, tmp_entry).async_get_api_instance( - tmp_context - ) - tools = [tool.name for tool in tmp_api.tools] + tools = [ + tool.name + for tool in PowerLLMAPI(self.hass, tmp_entry)._async_get_tools(tmp_context) + ] tools.append(CONF_DEFAULT) # Update memory prompts if user name has changed since options were last saved @@ -168,8 +165,6 @@ async def get_options_schema(self) -> vol.Schema: return vol.Schema( { - vol.Required(CONF_PROMPT_ENTITIES, default=True): bool, - vol.Required(CONF_INTENT_ENTITIES, default=True): bool, vol.Required( CONF_DUCKDUCKGO_REGION, default="wt-wt" ): selector.SelectSelector( diff --git a/custom_components/powerllm/llm_tools.py b/custom_components/powerllm/llm_tools.py index 0634ea6..a36cb28 100644 --- a/custom_components/powerllm/llm_tools.py +++ b/custom_components/powerllm/llm_tools.py @@ -10,20 +10,8 @@ from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints import voluptuous as vol -from homeassistant.core import HomeAssistant, State, callback, is_callback -from homeassistant.helpers import ( - area_registry as ar, - config_validation as cv, - device_registry as dr, - entity_registry as er, - floor_registry as fr, - intent, - llm, - template, -) -from homeassistant.helpers.template.helpers import resolve_area_id -from homeassistant.util import dt as dt_util -from homeassistant.util.json import JsonObjectType, JsonValueType +from homeassistant.core import HomeAssistant, callback, is_callback +from homeassistant.helpers import llm from .const import DOMAIN @@ -45,341 +33,6 @@ def async_is_applicable( return True -EXPORTED_ATTRIBUTES = [ - "device_class", - "message", - "all_day", - "start_time", - "end_time", - "location", - "description", - "hvac_modes", - "min_temp", - "max_temp", - "fan_modes", - "preset_modes", - "swing_modes", - "current_temperature", - "temperature", - "target_temp_high", - "target_temp_low", - "fan_mode", - "preset_mode", - "swing_mode", - "hvac_action", - "aux_heat", - "current_position", - "current_tilt_position", - "latitude", - "longitude", - "percentage", - "direction", - "oscillating", - "available_modes", - "max_humidity", - "min_humidity", - "action", - "current_humidity", - "humidity", - "mode", - "faces", - "total_faces", - "min", - "max", - "step", - "min_color_temp_kelvin", - "max_color_temp_kelvin", - "min_mireds", - "max_mireds", - "effect_list", - "supported_color_modes", - "color_mode", - "brightness", - "color_temp_kelvin", - "color_temp", - "hs_color", - "rgb_color", - "xy_color", - "rgbw_color", - "rgbww_color", - "effect", - "sound_mode_list", - "volume_level", - "is_volume_muted", - "media_content_type", - "media_duration", - "media_position", - "media_title", - "media_artist", - "media_album_name", - "media_track", - "media_series_title", - "media_season", - "media_episode", - "app_name", - "sound_mode", - "shuffle", - "repeat", - "source", - "options", - "battery_level", - "available_tones", - "elevation", - "rising", - "fan_speed_list", - "fan_speed", - "status", - "cleaned_area", - "operation_list", - "operation_mode", - "away_mode", - "temperature_unit", - "pressure", - "pressure_unit", - "wind_speed", - "wind_speed_unit", - "dew_point", - "cloud_coverage", - "persons", -] - - -def _area_name(hass: HomeAssistant, lookup_value: str) -> str | None: - """Get the area name from an area id, device id, or entity id.""" - area_reg = ar.async_get(hass) - if area := area_reg.async_get_area(lookup_value): - return area.name - - def _get_area_name(area_reg: ar.AreaRegistry, valid_area_id: str) -> str: - """Get area name from valid area ID.""" - area = area_reg.async_get_area(valid_area_id) - assert area - return area.name - - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - - try: - cv.entity_id(lookup_value) - except vol.Invalid: - pass - else: - if entity := ent_reg.async_get(lookup_value): - # If entity has an area ID, get the area name for that - if entity.area_id: - return _get_area_name(area_reg, entity.area_id) - # If entity has a device ID and the device exists with an area ID, get the - # area name for that - if ( - entity.device_id - and (device := dev_reg.async_get(entity.device_id)) - and device.area_id - ): - return _get_area_name(area_reg, device.area_id) - - if (device := dev_reg.async_get(lookup_value)) and device.area_id: - return _get_area_name(area_reg, device.area_id) - - return None - - -def _floor_name(hass: HomeAssistant, lookup_value: str) -> str | None: - """Get the floor name from a floor id.""" - floor_registry = fr.async_get(hass) - - # Check if it's a floor ID - if floor := floor_registry.async_get_floor(lookup_value): - return floor.name - - # Resolve to area ID and get floor name from area's floor - if aid := resolve_area_id(hass, lookup_value): - area_reg = ar.async_get(hass) - if ( - (area := area_reg.async_get_area(aid)) - and area.floor_id - and (floor := floor_registry.async_get_floor(area.floor_id)) - ): - return floor.name - - return None - - -def _format_state(hass: HomeAssistant, entity_state: State) -> dict[str, Any]: - """Format state for better understanding by a LLM.""" - entity_registry = er.async_get(hass) - entity_state = template.TemplateState(hass, entity_state, collect=False) - - result: dict[str, Any] = { - "name": entity_state.name, - "entity_id": entity_state.entity_id, - "state": entity_state.state_with_unit, - "last_changed": dt_util.get_age(entity_state.last_changed) + " ago", - } - - if registry_entry := entity_registry.async_get(entity_state.entity_id): - if area_name := _area_name(hass, entity_state.entity_id): - result["area"] = area_name - if floor_name := _floor_name(hass, entity_state.entity_id): - result["floor"] = floor_name - if len(registry_entry.aliases): - result["aliases"] = list(registry_entry.aliases) - - attributes: dict[str, Any] = { - attribute: value - for attribute, value in entity_state.attributes.items() - if attribute in EXPORTED_ATTRIBUTES - } - if attributes: - result["attributes"] = attributes - - return result - - -ADDITIONAL_DESCRIPTIONS = { - intent.INTENT_GET_STATE: ". Use it to get a list of devices matching certain " - "criteria or get additional details and attributes on them. ", -} - - -class PowerIntentTool(PowerLLMTool): - """Power LLM Tool representing an Intent.""" - - def __init__( - self, - name: str, - intent_handler: intent.IntentHandler, - response_entities: bool = False, - ) -> None: - """Init the class.""" - self.name = name - self.intent_handler = intent_handler - self._response_entities = response_entities - self.description = ( - intent_handler.description or f"Execute Home Assistant {self.name} intent" - ) - if name in ADDITIONAL_DESCRIPTIONS: - self.description += ADDITIONAL_DESCRIPTIONS[name] - self.extra_slots = None - if not (slot_schema := intent_handler.slot_schema): - return - - slot_schema = {**slot_schema} - extra_slots = set() - - for field in ("preferred_area_id", "preferred_floor_id"): - if field in slot_schema: - extra_slots.add(field) - del slot_schema[field] - - self.parameters = vol.Schema(slot_schema) - if extra_slots: - self.extra_slots = extra_slots - - async def async_call( - self, - hass: HomeAssistant, - tool_input: llm.ToolInput, - llm_context: llm.LLMContext, - ) -> JsonObjectType: - """Handle the intent.""" - slots = {key: {"value": val} for key, val in tool_input.tool_args.items()} - - if self.extra_slots and llm_context.device_id: - device_reg = dr.async_get(hass) - device = device_reg.async_get(llm_context.device_id) - - area: ar.AreaEntry | None = None - floor: fr.FloorEntry | None = None - if device: - area_reg = ar.async_get(hass) - if device.area_id and (area := area_reg.async_get_area(device.area_id)): - if area.floor_id: - floor_reg = fr.async_get(hass) - floor = floor_reg.async_get_floor(area.floor_id) - - for slot_name, slot_value in ( - ("preferred_area_id", area.id if area else None), - ("preferred_floor_id", floor.floor_id if floor else None), - ): - if slot_value and slot_name in self.extra_slots: - slots[slot_name] = {"value": slot_value} - - intent_response = await intent.async_handle( - hass=hass, - platform=llm_context.platform, - intent_type=self.intent_handler.intent_type, - slots=slots, - text_input=None, - context=llm_context.context, - language=llm_context.language, - assistant=llm_context.assistant, - device_id=llm_context.device_id, - ) - return PowerIntentResponseDict(intent_response, self._response_entities, hass) - - -class PowerIntentResponseDict(dict): - """Dictionary to represent an intent response resulting from a tool call.""" - - def __init__( - self, - intent_response: Any, - response_entities: bool = False, - hass: HomeAssistant | None = None, - ) -> None: - """Initialize the dictionary.""" - if not isinstance(intent_response, intent.IntentResponse): - super().__init__(intent_response) - return - - result = intent_response.as_dict() - if response_entities and intent_response.matched_states and hass is not None: - result["data"]["matched_states"] = [ - _format_state(hass, state) for state in intent_response.matched_states - ] - if response_entities and intent_response.unmatched_states and hass is not None: - result["data"]["unmatched_states"] = [ - _format_state(hass, state) for state in intent_response.unmatched_states - ] - del result["language"] - - def remove_empty(value: JsonValueType): - if isinstance(value, list): - for v in value: - remove_empty(v) - if not isinstance(value, dict): - return - for key in list(value.keys()): - remove_empty(value[key]) - if not value[key] and value[key] is not False: - del value[key] - - remove_empty(result) - - super().__init__(result) - self.original = intent_response - - -class PowerScriptTool(PowerLLMTool, llm.ScriptTool): - """Power LLM Tool representing a Script.""" - - -class PowerCalendarGetEventsTool(PowerLLMTool, llm.CalendarGetEventsTool): - """Power LLM Tool allowing querying a calendar.""" - - -class PowerTodoGetItemsTool(PowerLLMTool, llm.TodoGetItemsTool): - """Power LLM Tool allowing querying a to-do list.""" - - -class PowerGetLiveContextTool(PowerLLMTool, llm.GetLiveContextTool): - """Power LLM Tool for getting the current state of exposed entities.""" - - -class PowerGetDateTimeTool(PowerLLMTool, llm.GetDateTimeTool): - """Power LLM Tool for getting the current date and time.""" - - class PowerFunctionTool(PowerLLMTool): """LLM Tool representing an Python function. diff --git a/custom_components/powerllm/strings.json b/custom_components/powerllm/strings.json index 8fede11..93b45c4 100644 --- a/custom_components/powerllm/strings.json +++ b/custom_components/powerllm/strings.json @@ -8,16 +8,13 @@ }, "init": { "data": { - "prompt_entities": "Include exposed entities into api prompt", - "intent_entities": "Include relevant entities into intent tool response", "duckduckgo_region": "DuckDuckGo Region", "script_exposed_only": "Only allow referencing exposed entities in scripts" }, "data_description": { - "prompt_entities": "If disabled, LLM can still guess device name or query it using HassGetStates", - "intent_entities": "Also enables HassGetState intent for querying additional attributes of entities", "script_exposed_only": "Disabling would allow more complicated scripts, but could affect unexposed entities" - } + }, + "description": "Select both Assist and this PowerLLM API in your conversation agent to use home control together with PowerLLM tools." }, "memory_prompts": { "title": "Memory tool prompts", @@ -25,7 +22,7 @@ }, "tool_selection": { "title": "Tool selection", - "description": "Select tools available for this API.", + "description": "Select PowerLLM tools available for this API. These settings do not filter Assist tools.", "data": { "default": "Default" }, @@ -39,16 +36,13 @@ "step": { "init": { "data": { - "prompt_entities": "Include exposed entities into api prompt", - "intent_entities": "Include relevant entities into intent tool response", "duckduckgo_region": "DuckDuckGo Region", "script_exposed_only": "Only allow referencing exposed entities in scripts" }, "data_description": { - "prompt_entities": "If disabled, LLM can still guess device name or query it using HassGetStates", - "intent_entities": "Also enables HassGetState intent for querying additional attributes of entities", "script_exposed_only": "Disabling would allow more complicated scripts, but could affect unexposed entities" - } + }, + "description": "Select both Assist and this PowerLLM API in your conversation agent to use home control together with PowerLLM tools." }, "memory_prompts": { "title": "Memory tool prompts", @@ -56,13 +50,13 @@ }, "tool_selection": { "title": "Tool selection", - "description": "Select tools available for this API.", + "description": "Select PowerLLM tools available for this API. These settings do not filter Assist tools.", "data": { "default": "Default" }, "data_description": { "default": "Other tools not listed above" - } + } } } } diff --git a/custom_components/powerllm/tools/get_state.py b/custom_components/powerllm/tools/get_state.py new file mode 100644 index 0000000..eaeaa5e --- /dev/null +++ b/custom_components/powerllm/tools/get_state.py @@ -0,0 +1,317 @@ +"""Query detailed entity states without duplicating Assist's control tools.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + device_registry as dr, + entity_registry as er, + floor_registry as fr, + intent, + llm, + template, +) +from homeassistant.helpers.template.helpers import resolve_area_id +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonObjectType, JsonValueType + +from ..llm_tools import PowerLLMTool + +EXPORTED_ATTRIBUTES = [ + "device_class", + "message", + "all_day", + "start_time", + "end_time", + "location", + "description", + "hvac_modes", + "min_temp", + "max_temp", + "fan_modes", + "preset_modes", + "swing_modes", + "current_temperature", + "temperature", + "target_temp_high", + "target_temp_low", + "fan_mode", + "preset_mode", + "swing_mode", + "hvac_action", + "aux_heat", + "current_position", + "current_tilt_position", + "latitude", + "longitude", + "percentage", + "direction", + "oscillating", + "available_modes", + "max_humidity", + "min_humidity", + "action", + "current_humidity", + "humidity", + "mode", + "faces", + "total_faces", + "min", + "max", + "step", + "min_color_temp_kelvin", + "max_color_temp_kelvin", + "min_mireds", + "max_mireds", + "effect_list", + "supported_color_modes", + "color_mode", + "brightness", + "color_temp_kelvin", + "color_temp", + "hs_color", + "rgb_color", + "xy_color", + "rgbw_color", + "rgbww_color", + "effect", + "sound_mode_list", + "volume_level", + "is_volume_muted", + "media_content_type", + "media_duration", + "media_position", + "media_title", + "media_artist", + "media_album_name", + "media_track", + "media_series_title", + "media_season", + "media_episode", + "app_name", + "sound_mode", + "shuffle", + "repeat", + "source", + "options", + "battery_level", + "available_tones", + "elevation", + "rising", + "fan_speed_list", + "fan_speed", + "status", + "cleaned_area", + "operation_list", + "operation_mode", + "away_mode", + "temperature_unit", + "pressure", + "pressure_unit", + "wind_speed", + "wind_speed_unit", + "dew_point", + "cloud_coverage", + "persons", +] + + +def _area_name(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the area name from an area id, device id, or entity id.""" + area_reg = ar.async_get(hass) + if area := area_reg.async_get_area(lookup_value): + return area.name + + def _get_area_name(area_reg: ar.AreaRegistry, valid_area_id: str) -> str: + """Get area name from valid area ID.""" + area = area_reg.async_get_area(valid_area_id) + assert area + return area.name + + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + # If entity has an area ID, get the area name for that + if entity.area_id: + return _get_area_name(area_reg, entity.area_id) + # If entity has a device ID and the device exists with an area ID, get the + # area name for that + if ( + entity.device_id + and (device := dev_reg.async_get(entity.device_id)) + and device.area_id + ): + return _get_area_name(area_reg, device.area_id) + + if (device := dev_reg.async_get(lookup_value)) and device.area_id: + return _get_area_name(area_reg, device.area_id) + + return None + + +def _floor_name(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the floor name from a floor id.""" + floor_registry = fr.async_get(hass) + + # Check if it's a floor ID + if floor := floor_registry.async_get_floor(lookup_value): + return floor.name + + # Resolve to area ID and get floor name from area's floor + if aid := resolve_area_id(hass, lookup_value): + area_reg = ar.async_get(hass) + if ( + (area := area_reg.async_get_area(aid)) + and area.floor_id + and (floor := floor_registry.async_get_floor(area.floor_id)) + ): + return floor.name + + return None + + +def _format_state(hass: HomeAssistant, entity_state: State) -> dict[str, Any]: + """Format state for better understanding by a LLM.""" + entity_registry = er.async_get(hass) + entity_state = template.TemplateState(hass, entity_state, collect=False) + + result: dict[str, Any] = { + "name": entity_state.name, + "entity_id": entity_state.entity_id, + "state": entity_state.state_with_unit, + "last_changed": dt_util.get_age(entity_state.last_changed) + " ago", + } + + if registry_entry := entity_registry.async_get(entity_state.entity_id): + if area_name := _area_name(hass, entity_state.entity_id): + result["area"] = area_name + if floor_name := _floor_name(hass, entity_state.entity_id): + result["floor"] = floor_name + if aliases := [ + alias + for alias in er.async_get_entity_aliases(hass, registry_entry) + if alias + ]: + result["aliases"] = aliases + + attributes: dict[str, Any] = { + attribute: value + for attribute, value in entity_state.attributes.items() + if attribute in EXPORTED_ATTRIBUTES + } + if attributes: + result["attributes"] = attributes + + return result + + +class GetStateTool(PowerLLMTool): + """Query matching entities and return their detailed states.""" + + name = intent.INTENT_GET_STATE + description = ( + "Gets or checks the state of a device or entity. Use it to get a list of " + "devices matching criteria or additional details and attributes, including " + "entity IDs and how long ago their state changed." + ) + parameters = vol.Schema( + { + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("state"): vol.All(cv.ensure_list, [cv.string]), + } + ) + + async def async_call( + self, + hass: HomeAssistant, + tool_input: llm.ToolInput, + llm_context: llm.LLMContext, + ) -> JsonObjectType: + """Handle the intent.""" + args = self.parameters(tool_input.tool_args) + slots = {key: {"value": val} for key, val in args.items()} + + if llm_context.device_id: + device_reg = dr.async_get(hass) + device = device_reg.async_get(llm_context.device_id) + + area: ar.AreaEntry | None = None + floor: fr.FloorEntry | None = None + if device: + area_reg = ar.async_get(hass) + if device.area_id and (area := area_reg.async_get_area(device.area_id)): + if area.floor_id: + floor_reg = fr.async_get(hass) + floor = floor_reg.async_get_floor(area.floor_id) + + for slot_name, slot_value in ( + ("preferred_area_id", area.id if area else None), + ("preferred_floor_id", floor.floor_id if floor else None), + ): + if slot_value: + slots[slot_name] = {"value": slot_value} + + intent_response = await intent.async_handle( + hass=hass, + platform=llm_context.platform, + intent_type=self.name, + slots=slots, + text_input=None, + context=llm_context.context, + language=llm_context.language, + assistant=llm_context.assistant, + device_id=llm_context.device_id, + ) + return GetStateResponseDict(intent_response, hass) + + +class GetStateResponseDict(dict): + """Dictionary to represent an intent response resulting from a tool call.""" + + def __init__( + self, + intent_response: intent.IntentResponse, + hass: HomeAssistant, + ) -> None: + """Initialize the dictionary.""" + result = intent_response.as_dict() + if intent_response.matched_states: + result["data"]["matched_states"] = [ + _format_state(hass, state) for state in intent_response.matched_states + ] + if intent_response.unmatched_states: + result["data"]["unmatched_states"] = [ + _format_state(hass, state) for state in intent_response.unmatched_states + ] + del result["language"] + + def remove_empty(value: JsonValueType): + if isinstance(value, list): + for v in value: + remove_empty(v) + if not isinstance(value, dict): + return + for key in list(value.keys()): + remove_empty(value[key]) + if not value[key] and value[key] is not False: + del value[key] + + remove_empty(result) + + super().__init__(result) + self.original = intent_response diff --git a/custom_components/powerllm/tools/script.py b/custom_components/powerllm/tools/script.py index 36cc6e0..ec6f1ba 100644 --- a/custom_components/powerllm/tools/script.py +++ b/custom_components/powerllm/tools/script.py @@ -4,14 +4,13 @@ import logging import voluptuous as vol -from homeassistant.components.conversation import DOMAIN as CONVERSATION_DOMAIN from homeassistant.components.homeassistant.exposed_entities import async_should_expose -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.llm import LLMContext, ToolInput from homeassistant.helpers.script import Script from homeassistant.util.json import JsonObjectType -from homeassistant.util.yaml import parse_yaml +from homeassistant.util.yaml import dump, parse_yaml from ..const import DOMAIN from ..llm_tools import PowerLLMTool @@ -44,6 +43,20 @@ def __init__(self, exposed_only=True): """Initialize the tool.""" self._exposed_only = exposed_only + @callback + def prompt(self, hass: HomeAssistant, llm_context: LLMContext) -> str | None: + """Map exposed entity IDs to names for generated scripts.""" + entities = { + state.entity_id: state.name + for state in sorted( + hass.states.async_all(), key=lambda state: state.entity_id + ) + if async_should_expose(hass, llm_context.assistant, state.entity_id) + } + if not entities: + return None + return "Entity IDs for scripts (entity_id: name):\n" + dump(entities) + async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext ) -> JsonObjectType: @@ -82,7 +95,7 @@ async def async_call( if self._exposed_only: for entity_id in script.referenced_entities: - if not async_should_expose(hass, CONVERSATION_DOMAIN, entity_id): + if not async_should_expose(hass, llm_context.assistant, entity_id): raise RuntimeError( f"Referencing unknown or unexposed entity {entity_id}, please " "rewrite the script" diff --git a/custom_components/powerllm/translations/en.json b/custom_components/powerllm/translations/en.json index 8fede11..93b45c4 100644 --- a/custom_components/powerllm/translations/en.json +++ b/custom_components/powerllm/translations/en.json @@ -8,16 +8,13 @@ }, "init": { "data": { - "prompt_entities": "Include exposed entities into api prompt", - "intent_entities": "Include relevant entities into intent tool response", "duckduckgo_region": "DuckDuckGo Region", "script_exposed_only": "Only allow referencing exposed entities in scripts" }, "data_description": { - "prompt_entities": "If disabled, LLM can still guess device name or query it using HassGetStates", - "intent_entities": "Also enables HassGetState intent for querying additional attributes of entities", "script_exposed_only": "Disabling would allow more complicated scripts, but could affect unexposed entities" - } + }, + "description": "Select both Assist and this PowerLLM API in your conversation agent to use home control together with PowerLLM tools." }, "memory_prompts": { "title": "Memory tool prompts", @@ -25,7 +22,7 @@ }, "tool_selection": { "title": "Tool selection", - "description": "Select tools available for this API.", + "description": "Select PowerLLM tools available for this API. These settings do not filter Assist tools.", "data": { "default": "Default" }, @@ -39,16 +36,13 @@ "step": { "init": { "data": { - "prompt_entities": "Include exposed entities into api prompt", - "intent_entities": "Include relevant entities into intent tool response", "duckduckgo_region": "DuckDuckGo Region", "script_exposed_only": "Only allow referencing exposed entities in scripts" }, "data_description": { - "prompt_entities": "If disabled, LLM can still guess device name or query it using HassGetStates", - "intent_entities": "Also enables HassGetState intent for querying additional attributes of entities", "script_exposed_only": "Disabling would allow more complicated scripts, but could affect unexposed entities" - } + }, + "description": "Select both Assist and this PowerLLM API in your conversation agent to use home control together with PowerLLM tools." }, "memory_prompts": { "title": "Memory tool prompts", @@ -56,13 +50,13 @@ }, "tool_selection": { "title": "Tool selection", - "description": "Select tools available for this API.", + "description": "Select PowerLLM tools available for this API. These settings do not filter Assist tools.", "data": { "default": "Default" }, "data_description": { "default": "Other tools not listed above" - } + } } } } diff --git a/hacs.json b/hacs.json index 993aa51..d83f832 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "Power LLM", - "homeassistant": "2025.8.0" + "homeassistant": "2026.8.0" } diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d30bcf2..46433e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,6 +10,7 @@ home_assistant_intents service_identity pyspeex_noise hassil +gazetteer-matcher mutagen ha-ffmpeg pymicro-vad diff --git a/setup.cfg b/setup.cfg index e3e52f0..6c77ed1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -101,12 +101,11 @@ skip = ./.*,test/* quiet-level = 2 [tox:tox] -envlist = cov_erase, py313, py314, lint, black, coverage +envlist = cov_erase, py314, lint, black, coverage skip_missing_interpreters = True [gh-actions] python = - 3.13: py313 3.14: py314, lint, black, coverage [testenv] @@ -156,4 +155,3 @@ skip_install = True [coverage:run] relative_files = True - diff --git a/setup.py b/setup.py index 7efa49e..93fa11e 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,6 @@ def long_description(): license="GNU General Public License v3.0", keywords="homeassistant home-assistant llm generative ai chatgpt chat gpt gemini", packages=find_packages(exclude=["tests"]), - python_requires=">=3.12", + python_requires=">=3.14", tests_require=["pytest"], ) diff --git a/tests/conftest.py b/tests/conftest.py index 34e037d..ad82b91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,7 +70,7 @@ def llm_context() -> llm.LLMContext: platform="test_platform", context=Context(user_id="12345"), language=None, - assistant=None, + assistant="conversation", device_id=None, ) diff --git a/tests/const.py b/tests/const.py index 84a17d5..5f538db 100644 --- a/tests/const.py +++ b/tests/const.py @@ -4,9 +4,7 @@ from custom_components.powerllm.const import ( CONF_DUCKDUCKGO_REGION, - CONF_INTENT_ENTITIES, CONF_MEMORY_PROMPTS, - CONF_PROMPT_ENTITIES, CONF_SCRIPT_EXPOSED_ONLY, CONF_TOOL_SELECTION, ) @@ -17,19 +15,11 @@ } MOCK_OPTIONS_CONFIG = { - CONF_PROMPT_ENTITIES: True, - CONF_INTENT_ENTITIES: True, CONF_DUCKDUCKGO_REGION: "wt-wt", CONF_SCRIPT_EXPOSED_ONLY: True, CONF_MEMORY_PROMPTS: {}, CONF_TOOL_SELECTION: { - "GetDateTime": True, - "HassCancelAllTimers": True, "HassGetState": True, - "HassSetPosition": True, - "HassStopMoving": True, - "HassTurnOff": True, - "HassTurnOn": True, "homeassistant_script": True, "memory": True, "news": True, diff --git a/tests/snapshots/test_http.ambr b/tests/snapshots/test_http.ambr deleted file mode 100644 index 6e5bf6b..0000000 --- a/tests/snapshots/test_http.ambr +++ /dev/null @@ -1,156 +0,0 @@ -# serializer version: 1 -# name: test_http_tool_list - dict({ - 'prompt': 'Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.', - 'tools': list([ - dict({ - 'description': "Turns on/opens/presses a device or entity. For locks, this performs a 'lock' action. Use for requests like 'turn on', 'activate', 'enable', or 'lock'.", - 'name': 'HassTurnOn', - 'parameters': dict({ - 'properties': dict({ - 'area': dict({ - 'type': 'string', - }), - 'device_class': dict({ - 'items': dict({ - 'enum': list([ - 'awning', - 'blind', - 'curtain', - 'damper', - 'door', - 'garage', - 'gas', - 'gate', - 'identify', - 'outlet', - 'receiver', - 'restart', - 'shade', - 'shutter', - 'speaker', - 'switch', - 'tv', - 'update', - 'water', - 'window', - ]), - 'type': 'string', - }), - 'type': 'array', - }), - 'domain': dict({ - 'items': dict({ - 'type': 'string', - }), - 'type': 'array', - }), - 'floor': dict({ - 'type': 'string', - }), - 'name': dict({ - 'type': 'string', - }), - }), - 'required': list([ - ]), - 'type': 'object', - }), - }), - dict({ - 'description': "Turns off/closes a device or entity. For locks, this performs an 'unlock' action. Use for requests like 'turn off', 'deactivate', 'disable', or 'unlock'.", - 'name': 'HassTurnOff', - 'parameters': dict({ - 'properties': dict({ - 'area': dict({ - 'type': 'string', - }), - 'device_class': dict({ - 'items': dict({ - 'enum': list([ - 'awning', - 'blind', - 'curtain', - 'damper', - 'door', - 'garage', - 'gas', - 'gate', - 'identify', - 'outlet', - 'receiver', - 'restart', - 'shade', - 'shutter', - 'speaker', - 'switch', - 'tv', - 'update', - 'water', - 'window', - ]), - 'type': 'string', - }), - 'type': 'array', - }), - 'domain': dict({ - 'items': dict({ - 'type': 'string', - }), - 'type': 'array', - }), - 'floor': dict({ - 'type': 'string', - }), - 'name': dict({ - 'type': 'string', - }), - }), - 'required': list([ - ]), - 'type': 'object', - }), - }), - dict({ - 'description': 'Cancels all timers', - 'name': 'HassCancelAllTimers', - 'parameters': dict({ - 'properties': dict({ - 'area': dict({ - 'type': 'string', - }), - }), - 'required': list([ - ]), - 'type': 'object', - }), - }), - dict({ - 'description': 'Orders beer', - 'name': 'OrderBeer', - 'parameters': dict({ - 'properties': dict({ - 'type': dict({ - 'type': 'string', - }), - }), - 'required': list([ - 'type', - ]), - 'type': 'object', - }), - }), - dict({ - 'description': 'Provides the current date and time.', - 'name': 'GetDateTime', - 'parameters': dict({ - 'properties': dict({ - }), - 'required': list([ - ]), - 'type': 'object', - }), - }), - ]), - }) -# --- diff --git a/tests/test_api.py b/tests/test_api.py index bcd6f24..64d4e29 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,743 +1,136 @@ -"""Test powerllm config flow.""" +"""Tests for PowerLLM as an API that complements Assist.""" -from decimal import Decimal -from unittest.mock import patch - -import voluptuous as vol +import pytest from homeassistant.components.homeassistant.exposed_entities import async_expose_entity -from homeassistant.components.intent import async_register_timer_handler -from homeassistant.core import Context, HomeAssistant, State -from homeassistant.helpers import ( - area_registry as ar, - config_validation as cv, - device_registry as dr, - entity_registry as er, - floor_registry as fr, - intent, - llm, -) -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent, llm from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.powerllm.const import CONF_PROMPT_ENTITIES - -INTENT_TOOLS = [ - "HassTurnOn", - "HassTurnOff", - "HassGetState", - "HassSetPosition", - "HassStopMoving", -] - -TIMER_TOOLS = [ - "HassStartTimer", - "HassCancelTimer", - "HassCancelAllTimers", - "HassIncreaseTimer", - "HassDecreaseTimer", - "HassPauseTimer", - "HassUnpauseTimer", - "HassTimerStatus", -] - -ASSIST_TOOLS = [ - "GetDateTime", -] - -POWERLLM_TOOLS = [ - "homeassistant_script", - "websearch", - "news", - "memory", - "python_code_execute", - "web_scrape", -] - - -def test_test(hass): - """Workaround for https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/discussions/160.""" - - -async def test_powerllm_api( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, - area_registry: ar.AreaRegistry, - floor_registry: fr.FloorRegistry, - mock_init_component, -) -> None: - """Test Assist API.""" - entity_registry.async_get_or_create( - "light", - "kitchen", - "mock-id-kitchen", - original_name="Kitchen", - suggested_object_id="kitchen", - ).write_unavailable_state(hass) - - test_context = Context(user_id="12345") - llm_context = llm.LLMContext( - platform="test_platform", - context=test_context, - language="*", - assistant="conversation", - device_id=None, - ) - schema = { - vol.Optional("area"): cv.string, - vol.Optional("floor"): cv.string, - vol.Optional("preferred_area_id"): cv.string, - vol.Optional("preferred_floor_id"): cv.string, - } - - class MyIntentHandler(intent.IntentHandler): - intent_type = "test_intent" - slot_schema = schema - platforms = set() # Match none - - intent_handler = MyIntentHandler() +from custom_components.powerllm import llm_tool +from custom_components.powerllm.const import CONF_MEMORY_PROMPTS, CONF_TOOL_SELECTION - intent.async_register(hass, intent_handler) - assert len(llm.async_get_apis(hass)) == 2 - api = await llm.async_get_api(hass, "powerllm", llm_context) - assert len(api.tools) == len(INTENT_TOOLS) + len(POWERLLM_TOOLS) + 1 - - # Match all - intent_handler.platforms = None - - api = await llm.async_get_api(hass, "powerllm", llm_context) - assert len(api.tools) == len(INTENT_TOOLS) + len(POWERLLM_TOOLS) + 2 +@pytest.mark.usefixtures("mock_init_component") +async def test_powerllm_tools(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Expose extras without discovering arbitrary intents or duplicating Assist.""" - # Match specific domain - intent_handler.platforms = {"light"} + class CustomIntent(intent.IntentHandler): + intent_type = "CustomIntent" + intent.async_register(hass, CustomIntent()) api = await llm.async_get_api(hass, "powerllm", llm_context) - assert len(api.tools) == len(INTENT_TOOLS) + len(POWERLLM_TOOLS) + 2 - tool = api.tools[4] - assert tool.name == "test_intent" - assert tool.description == "Execute Home Assistant test_intent intent" - assert tool.parameters == vol.Schema( - { - vol.Optional("area"): cv.string, - vol.Optional("floor"): cv.string, - # No preferred_area_id, preferred_floor_id - } - ) - assert str(tool) == "" - - assert test_context.json_fragment # To reproduce an error case in tracing - intent_response = intent.IntentResponse("*") - intent_response.async_set_states( - [State("light.matched", "on")], [State("light.unmatched", "on")] - ) - intent_response.async_set_speech("Some speech") - intent_response.async_set_card("Card title", "card content") - intent_response.async_set_speech_slots({"hello": 1}) - intent_response.async_set_reprompt("Do it again") - tool_input = llm.ToolInput( - tool_name="test_intent", - tool_args={"area": "kitchen", "floor": "ground_floor"}, - ) - - with patch( - "homeassistant.helpers.intent.async_handle", return_value=intent_response - ) as mock_intent_handle: - response = await api.async_call_tool(tool_input) - - mock_intent_handle.assert_awaited_once_with( - hass=hass, - platform="test_platform", - intent_type="test_intent", - slots={ - "area": {"value": "kitchen"}, - "floor": {"value": "ground_floor"}, - }, - text_input=None, - context=test_context, - language="*", - assistant="conversation", - device_id=None, - ) - assert response == { - "card": { - "simple": { - "content": "card content", - "title": "Card title", - }, - }, - "data": { - "matched_states": [ - { - "entity_id": "light.matched", - "last_changed": "0 seconds ago", - "name": "matched", - "state": "on", - }, - ], - "unmatched_states": [ - { - "entity_id": "light.unmatched", - "last_changed": "0 seconds ago", - "name": "unmatched", - "state": "on", - }, - ], - }, - "reprompt": { - "plain": { - "reprompt": "Do it again", - }, - }, - "response_type": "action_done", - "speech": { - "plain": { - "speech": "Some speech", - }, - }, - "speech_slots": { - "hello": 1, - }, - } - - # Call with a device/area/floor - entry = MockConfigEntry(title=None) - entry.add_to_hass(hass) - - device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "1234")}, - suggested_area="Test Area", - ) - area = area_registry.async_get_area_by_name("Test Area") - floor = floor_registry.async_create("2") - area_registry.async_update(area.id, floor_id=floor.floor_id) - llm_context.device_id = device.id - - with patch( - "homeassistant.helpers.intent.async_handle", return_value=intent_response - ) as mock_intent_handle: - response = await api.async_call_tool(tool_input) - - mock_intent_handle.assert_awaited_once_with( - hass=hass, - platform="test_platform", - intent_type="test_intent", - slots={ - "area": {"value": "kitchen"}, - "floor": {"value": "ground_floor"}, - "preferred_area_id": {"value": area.id}, - "preferred_floor_id": {"value": floor.floor_id}, - }, - text_input=None, - context=test_context, - language="*", - assistant="conversation", - device_id=device.id, - ) - assert response == { - "card": { - "simple": { - "content": "card content", - "title": "Card title", - }, - }, - "data": { - "matched_states": [ - { - "entity_id": "light.matched", - "last_changed": "0 seconds ago", - "name": "matched", - "state": "on", - }, - ], - "unmatched_states": [ - { - "entity_id": "light.unmatched", - "last_changed": "0 seconds ago", - "name": "unmatched", - "state": "on", - }, - ], - }, - "response_type": "action_done", - "reprompt": { - "plain": { - "reprompt": "Do it again", - }, - }, - "speech": { - "plain": { - "speech": "Some speech", - }, - }, - "speech_slots": { - "hello": 1, - }, + assert {tool.name for tool in api.tools} == { + "HassGetState", + "homeassistant_script", + "websearch", + "news", + "memory", + "python_code_execute", + "web_scrape", } -async def test_powerllm_api_get_timer_tools( - hass: HomeAssistant, llm_context: llm.LLMContext, mock_init_component -) -> None: - """Test getting timer tools with Assist API.""" - api = await llm.async_get_api(hass, "powerllm", llm_context) - - assert "HassStartTimer" not in [tool.name for tool in api.tools] - - llm_context.device_id = "test_device" - - async_register_timer_handler(hass, "test_device", lambda *args: None) - - api = await llm.async_get_api(hass, "powerllm", llm_context) - assert "HassStartTimer" in [tool.name for tool in api.tools] - - -async def test_powerllm_api_tools( - hass: HomeAssistant, llm_context: llm.LLMContext, mock_init_component -) -> None: - """Test getting timer tools with Assist API.""" - llm_context.device_id = "test_device" - - async_register_timer_handler(hass, "test_device", lambda *args: None) - - class MyIntentHandler(intent.IntentHandler): - intent_type = "Super crazy intent with unique nåme" - description = "my intent handler" - - intent.async_register(hass, MyIntentHandler()) - - api = await llm.async_get_api(hass, "powerllm", llm_context) - assert [tool.name for tool in api.tools] == [ - *INTENT_TOOLS, - *TIMER_TOOLS, - "Super_crazy_intent_with_unique_name", - *ASSIST_TOOLS, - *POWERLLM_TOOLS, - ] - - -async def test_powerllm_api_description( - hass: HomeAssistant, llm_context: llm.LLMContext, mock_init_component -) -> None: - """Test intent description with Assist API.""" - - class MyIntentHandler(intent.IntentHandler): - intent_type = "test_intent" - description = "my intent handler" - - intent.async_register(hass, MyIntentHandler()) - - assert len(llm.async_get_apis(hass)) == 2 - api = await llm.async_get_api(hass, "powerllm", llm_context) +@pytest.mark.usefixtures("mock_init_component") +async def test_merged_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Selecting both APIs merges prompts and dispatches tools to their owner.""" + hass.states.async_set("light.kitchen", "on", {"friendly_name": "Kitchen"}) + async_expose_entity(hass, "conversation", "light.kitchen", True) + + @llm_tool(hass) + @callback + def echo(value: str) -> dict[str, str]: + """Echo a value.""" + return {"value": value} + + api = await llm.async_get_api(hass, ["assist", "powerllm"], llm_context) + tools = {tool.name for tool in api.tools} + assert "assist__HassTurnOn" in tools + assert "assist__GetDateTime" in tools + assert "powerllm__HassGetState" in tools + assert "powerllm__HassTurnOn" not in tools + assert "Static Context:" in api.api_prompt + assert "Entity IDs for scripts" in api.api_prompt + assert await api.async_call_tool( + llm.ToolInput(tool_name="powerllm__echo", tool_args={"value": "works"}) + ) == {"value": "works"} assert ( - len(api.tools) - == len(INTENT_TOOLS) + len(ASSIST_TOOLS) + len(POWERLLM_TOOLS) + 2 + await api.async_call_tool( + llm.ToolInput(tool_name="assist__GetDateTime", tool_args={}) + ) + )["success"] is True + result = await api.async_call_tool( + llm.ToolInput(tool_name="powerllm__HassGetState", tool_args={"name": "Kitchen"}) ) - tool = api.tools[len(INTENT_TOOLS) + 1] - assert tool.name == "test_intent" - assert tool.description == "my intent handler" + assert result["data"]["matched_states"][0]["entity_id"] == "light.kitchen" -async def test_powerllm_api_prompt( +@pytest.mark.usefixtures("mock_init_component") +@pytest.mark.parametrize("enabled", [True, False]) +async def test_script_prompt_selection( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - area_registry: ar.AreaRegistry, - floor_registry: fr.FloorRegistry, - mock_init_component, - mock_config_entry, + llm_context: llm.LLMContext, + mock_config_entry: MockConfigEntry, + enabled: bool, ) -> None: - """Test prompt for the assist API.""" - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, + """Only the enabled script tool contributes the entity ID mapping.""" + hass.states.async_set( + "light.kitchen", "on", {"friendly_name": "Kitchen", "brightness": 100} + ) + hass.states.async_set("light.hidden", "off", {"friendly_name": "Hidden"}) + async_expose_entity(hass, "conversation", "light.kitchen", True) + async_expose_entity(hass, "conversation", "light.hidden", False) + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + CONF_TOOL_SELECTION: {"default": False, "homeassistant_script": enabled}, + }, ) api = await llm.async_get_api(hass, "powerllm", llm_context) assert api.api_prompt == ( - "Only if the user wants to control a device, tell them to expose entities to " - "their voice assistant in Home Assistant." - ) - - # Expose entities - - # Create a script with a unique ID - assert await async_setup_component( - hass, - "script", - { - "script": { - "test_script": { - "description": "This is a test script", - "sequence": [], - "fields": { - "beer": {"description": "Number of beers"}, - "wine": {}, - }, - } - } - }, - ) - async_expose_entity(hass, "conversation", "script.test_script", True) - - entry = MockConfigEntry(title=None) - entry.add_to_hass(hass) - device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "1234")}, - suggested_area="Test Area", - ) - area = area_registry.async_get_area_by_name("Test Area") - area_registry.async_update(area.id, aliases=["Alternative name"]) - entry1 = entity_registry.async_get_or_create( - "light", - "kitchen", - "mock-id-kitchen", - original_name="Kitchen", - suggested_object_id="kitchen", - ) - entry2 = entity_registry.async_get_or_create( - "light", - "living_room", - "mock-id-living-room", - original_name="Living Room", - suggested_object_id="living_room", - device_id=device.id, + "Entity IDs for scripts (entity_id: name):\nlight.kitchen: Kitchen\n" + if enabled + else "" ) - hass.states.async_set( - entry1.entity_id, - "on", - {"friendly_name": "Kitchen", "temperature": Decimal("0.9"), "humidity": 65}, + assert [tool.name for tool in api.tools] == ( + ["homeassistant_script"] if enabled else [] ) - hass.states.async_set(entry2.entity_id, "on", {"friendly_name": "Living Room"}) - def create_entity( - device: dr.DeviceEntry, write_state=True, aliases: set[str] | None = None - ) -> None: - """Create an entity for a device and track entity_id.""" - entity = entity_registry.async_get_or_create( - "light", - "test", - device.id, - device_id=device.id, - original_name=str(device.name or "Unnamed Device"), - suggested_object_id=str(device.name or "unnamed_device"), - ) - if aliases: - entity_registry.async_update_entity(entity.entity_id, aliases=aliases) - if write_state: - entity.write_unavailable_state(hass) - create_entity( - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "1234")}, - name="Test Device", - manufacturer="Test Manufacturer", - model="Test Model", - suggested_area="Test Area", - ), - aliases={"my test light"}, - ) - for i in range(3): - create_entity( - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", f"{i}abcd")}, - name="Test Service", - manufacturer="Test Manufacturer", - model="Test Model", - suggested_area="Test Area", - entry_type=dr.DeviceEntryType.SERVICE, - ) - ) - create_entity( - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "5678")}, - name="Test Device 2", - manufacturer="Test Manufacturer 2", - model="Device 2", - suggested_area="Test Area 2", - ) - ) - create_entity( - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876")}, - name="Test Device 3", - manufacturer="Test Manufacturer 3", - model="Test Model 3A", - suggested_area="Test Area 2", - ) - ) - create_entity( - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "qwer")}, - name="Test Device 4", - suggested_area="Test Area 2", - ) - ) - device2 = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876-disabled")}, - name="Test Device 3 - disabled", - manufacturer="Test Manufacturer 3", - model="Test Model 3A", - suggested_area="Test Area 2", - ) - device_registry.async_update_device( - device2.id, disabled_by=dr.DeviceEntryDisabler.USER - ) - create_entity(device2, False) - create_entity( - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876-no-name")}, - manufacturer="Test Manufacturer NoName", - model="Test Model NoName", - suggested_area="Test Area 2", - ) - ) - create_entity( - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876-integer-values")}, - name=1, - manufacturer=2, - model=3, - suggested_area="Test Area 2", - ) - ) - - lock_device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test2", "12345")}, - name="Test Lock", - manufacturer="Test Manufacturer", - model="Test Model", - suggested_area="Test Area", - ) - - lock_entity = entity_registry.async_get_or_create( - "lock", - "test", - lock_device.id, - device_id=lock_device.id, - original_name=str(device.name or "Unnamed Device"), - suggested_object_id=str(device.name or "unnamed_device"), - ) - lock_entity.write_unavailable_state(hass) - - exposed_entities_prompt = ( - "Live Context: An overview of the areas and the devices in this smart home:" """ -- names: '1' - domain: light - state: unavailable - areas: Test Area 2 -- names: Kitchen - domain: light - state: 'on' - attributes: - temperature: '0.9' - humidity: '65' -- names: Living Room - domain: light - state: 'on' - areas: Test Area, Alternative name -- names: Test Device, my test light - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Device 2 - domain: light - state: unavailable - areas: Test Area 2 -- names: Test Device 3 - domain: light - state: unavailable - areas: Test Area 2 -- names: Test Device 4 - domain: light - state: unavailable - areas: Test Area 2 -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Test Service - domain: light - state: unavailable - areas: Test Area, Alternative name -- names: Unnamed Device - domain: light - state: unavailable - areas: Test Area 2 -""" - ) - stateless_exposed_entities_prompt = ( - "Static Context: An overview of the areas and the devices in this smart home:" - """ -light.1: - names: '1' - domain: light - areas: Test Area 2 -light.kitchen: - names: Kitchen - domain: light -light.living_room: - names: Living Room - domain: light - areas: Test Area, Alternative name -light.test_device: - names: Test Device, my test light - domain: light - areas: Test Area, Alternative name -light.test_device_2: - names: Test Device 2 - domain: light - areas: Test Area 2 -light.test_device_3: - names: Test Device 3 - domain: light - areas: Test Area 2 -light.test_device_4: - names: Test Device 4 - domain: light - areas: Test Area 2 -light.test_service: - names: Test Service - domain: light - areas: Test Area, Alternative name -light.test_service_2: - names: Test Service - domain: light - areas: Test Area, Alternative name -light.test_service_3: - names: Test Service - domain: light - areas: Test Area, Alternative name -light.unnamed_device: - names: Unnamed Device - domain: light - areas: Test Area 2 -""" - ) - first_part_prompt = ( - "When controlling Home Assistant always call the intent tools. " - "When controlling a device, prefer passing just name and domain. " - "When controlling an area, prefer passing just area name and domain." - ) - lock_prompt = "Use HassTurnOn to lock and HassTurnOff to unlock a lock." - no_timer_prompt = "This device is not able to start timers." - - area_prompt = ( - "When a user asks to turn on all devices of a specific type, " - "ask user to specify an area, unless there is only one device of that type." - ) - dynamic_context_prompt = ( - "You ARE equipped to answer questions about the current state of" - """ -the home using the `GetLiveContext` tool. This is a primary function. Do not state """ - """you lack the -functionality if the question requires live data. -If the user asks about device existence/type (e.g., "Do I have lights in the """ - """bedroom?"): Answer -from the static context below. -If the user asks about the CURRENT state, value, or mode (e.g., "Is the lock locked?", -"Is the fan on?", "What mode is the thermostat in?", "What is the temperature """ - """outside?"): - 1. Recognize this requires live data. - 2. You MUST call `GetLiveContext`. This tool will provide the needed real-time """ - """information (like temperature from the local weather, lock status, etc.). - 3. Use the tool's response** to answer the user accurately (e.g., "The """ - """temperature outside is [value from tool]."). -For general knowledge questions not about the home: Answer truthfully from internal """ - """knowledge. -""" +@pytest.mark.usefixtures("mock_init_component") +async def test_memory_prompt_without_entities( + hass: HomeAssistant, + llm_context: llm.LLMContext, + mock_config_entry: MockConfigEntry, +) -> None: + """Memory prompts remain available even when there are no exposed entities.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + CONF_MEMORY_PROMPTS: {"User": {"12345": "Likes tea"}}, + }, ) api = await llm.async_get_api(hass, "powerllm", llm_context) - assert api.api_prompt == (f"""{first_part_prompt} -{area_prompt} -{no_timer_prompt} -{dynamic_context_prompt} -{stateless_exposed_entities_prompt}""") + assert "Likes tea" in api.api_prompt + assert "expose entities" not in api.api_prompt + assert "Entity IDs for scripts" not in api.api_prompt - # Verify that the GetLiveContext tool returns the same results - # as the exposed_entities_prompt - result = await api.async_call_tool( - llm.ToolInput(tool_name="GetLiveContext", tool_args={}) - ) - assert result == { - "success": True, - "result": exposed_entities_prompt, - } - # Fake that request is made from a specific device ID with an area - llm_context.device_id = device.id - area_prompt = ( - "You are in area Test Area and all generic commands like 'turn on the lights' " - "should target this area." - ) - api = await llm.async_get_api(hass, "powerllm", llm_context) - assert api.api_prompt == (f"""{first_part_prompt} -{area_prompt} -{no_timer_prompt} -{dynamic_context_prompt} -{stateless_exposed_entities_prompt}""") - - # Add floor - floor = floor_registry.async_create("2") - area_registry.async_update(area.id, floor_id=floor.floor_id) - area_prompt = ( - "You are in area Test Area (floor 2) and all generic commands like " - "'turn on the lights' should target this area." +@pytest.mark.usefixtures("mock_init_component") +async def test_get_state_selection( + hass: HomeAssistant, + llm_context: llm.LLMContext, + mock_config_entry: MockConfigEntry, +) -> None: + """State queries use the normal tool selector independently of retired options.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + "intent_entities": False, + CONF_TOOL_SELECTION: {"default": False, "HassGetState": True}, + }, ) api = await llm.async_get_api(hass, "powerllm", llm_context) - assert api.api_prompt == (f"""{first_part_prompt} -{area_prompt} -{no_timer_prompt} -{dynamic_context_prompt} -{stateless_exposed_entities_prompt}""") - - # Register device for timers - async_register_timer_handler(hass, device.id, lambda *args: None) - - api = await llm.async_get_api(hass, "powerllm", llm_context) - # The no_timer_prompt is gone - assert api.api_prompt == (f"""{first_part_prompt} -{area_prompt} -{dynamic_context_prompt} -{stateless_exposed_entities_prompt}""") - - # Expose lock - async_expose_entity(hass, "conversation", lock_entity.entity_id, True) - stateless_exposed_entities_prompt += """lock.unnamed_device: - names: Unnamed Device - domain: lock - areas: Test Area, Alternative name -""" - - api = await llm.async_get_api(hass, "powerllm", llm_context) - assert api.api_prompt == (f"""{first_part_prompt} -{area_prompt} -{dynamic_context_prompt} -{lock_prompt} -{stateless_exposed_entities_prompt}""") - - options = mock_config_entry.options.copy() - options[CONF_PROMPT_ENTITIES] = False - hass.config_entries.async_update_entry(mock_config_entry, options=options) - - api = await llm.async_get_api(hass, "powerllm", llm_context) - assert api.api_prompt == (f"""{first_part_prompt} -{area_prompt}""") + assert [tool.name for tool in api.tools] == ["HassGetState"] diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 338b49b..a56120b 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -6,11 +6,11 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.const import CONF_DEFAULT from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.powerllm import async_migrate_entry from custom_components.powerllm.const import ( CONF_DUCKDUCKGO_REGION, - CONF_INTENT_ENTITIES, - CONF_PROMPT_ENTITIES, CONF_SCRIPT_EXPOSED_ONLY, CONF_TOOL_SELECTION, DOMAIN, @@ -29,10 +29,6 @@ def bypass_setup_fixture(): yield -def test_test(hass): - """Workaround for https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/discussions/160.""" - - async def test_config_flow(hass: HomeAssistant): """Test a successful config flow.""" # Init first step @@ -97,8 +93,6 @@ async def test_options_flow( options = await hass.config_entries.options.async_configure( options["flow_id"], { - CONF_PROMPT_ENTITIES: False, - CONF_INTENT_ENTITIES: False, CONF_DUCKDUCKGO_REGION: "us-en", CONF_SCRIPT_EXPOSED_ONLY: False, }, @@ -118,8 +112,35 @@ async def test_options_flow( {"default": True}, ) assert options["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY - assert options["data"][CONF_PROMPT_ENTITIES] is False - assert options["data"][CONF_INTENT_ENTITIES] is False assert options["data"][CONF_DUCKDUCKGO_REGION] == "us-en" assert options["data"][CONF_SCRIPT_EXPOSED_ONLY] is False assert options["data"][CONF_TOOL_SELECTION][CONF_DEFAULT] is True + + +@pytest.mark.parametrize( + ("legacy_enabled", "selected", "expected"), + [(True, True, True), (True, False, False), (False, True, False)], +) +async def test_migrate_options( + hass: HomeAssistant, legacy_enabled: bool, selected: bool, expected: bool +) -> None: + """Retire Assist options without re-enabling a previously disabled state tool.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + version=1, + minor_version=2, + options={ + **MOCK_OPTIONS_CONFIG, + "prompt_entities": False, + "intent_entities": legacy_enabled, + CONF_TOOL_SELECTION: {"HassGetState": selected, "custom_tool": False}, + }, + ) + entry.add_to_hass(hass) + assert await async_migrate_entry(hass, entry) + assert entry.minor_version == 3 + assert "prompt_entities" not in entry.options + assert "intent_entities" not in entry.options + assert entry.options[CONF_TOOL_SELECTION]["HassGetState"] is expected + assert entry.options[CONF_TOOL_SELECTION]["custom_tool"] is False diff --git a/tests/test_http.py b/tests/test_http.py index d264ad9..20bd960 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1,155 +1,94 @@ """Tests for LLM Tools HTTP API.""" -import voluptuous as vol +import pytest +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, intent -from pytest_homeassistant_custom_component.common import MockUser from pytest_homeassistant_custom_component.typing import ClientSessionGenerator -from syrupy.assertion import SnapshotAssertion -def test_test(hass): - """Workaround for https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/discussions/160.""" - - -async def test_http_api_list( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_admin_user: MockUser, - mock_init_component, -) -> None: - """Test LLM API list via HTTP API.""" +@pytest.mark.usefixtures("mock_init_component") +async def test_http_api_list(hass_client: ClientSessionGenerator) -> None: + """Both API owners remain available through HTTP.""" client = await hass_client() - resp = await client.get("/api/powerllm") - - assert resp.status == 200 - data = await resp.json() - - assert data == [ + response = await client.get("/api/powerllm") + assert response.status == 200 + assert await response.json() == [ {"name": "Assist", "id": "assist"}, {"name": "PowerLLM", "id": "powerllm"}, ] +@pytest.mark.usefixtures("mock_init_component") +@pytest.mark.parametrize("method", ["get", "post"]) async def test_http_tool_list( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_admin_user: MockUser, - snapshot: SnapshotAssertion, - mock_init_component, + hass: HomeAssistant, hass_client: ClientSessionGenerator, method: str ) -> None: - """Test LLM Tool list via HTTP API.""" - - class TestIntentHandler(intent.IntentHandler): - """Test Intent Handler.""" - - intent_type = "OrderBeer" - description = "Orders beer" - - @property - def slot_schema(self) -> dict | None: - """Return a slot schema.""" - return {vol.Required("type"): cv.string} - - async def async_handle(self, intent): - """Handle the intent.""" - assert intent.context.user_id == hass_admin_user.id - slots = self.async_validate_slots(intent.slots) - response = intent.create_response() - response.async_set_speech(f"I've ordered a {slots['type']['value']}!") - response.async_set_card( - "Beer ordered", f"You chose a {slots['type']['value']}." - ) - return response - - intent.async_register(hass, TestIntentHandler()) - + """Clients can fetch each API separately, with no duplicate control tools.""" + hass.states.async_set("light.kitchen", "on", {"friendly_name": "Kitchen"}) + async_expose_entity(hass, "conversation", "light.kitchen", True) client = await hass_client() - resp = await client.get("/api/powerllm/assist") - - assert resp.status == 200 - data = await resp.json() - - data["tools"][0]["parameters"]["properties"]["device_class"]["items"]["enum"].sort() - data["tools"][1]["parameters"]["properties"]["device_class"]["items"]["enum"].sort() - assert data == snapshot - - resp = await client.get("/api/powerllm/non-existent") - assert resp.status == 404 - - + response = await client.request(method, "/api/powerllm/powerllm") + assert response.status == 200 + data = await response.json() + tools = {tool["name"]: tool for tool in data["tools"]} + assert "HassGetState" in tools + assert "HassTurnOn" not in tools + assert "domain" in tools["HassGetState"]["parameters"]["properties"] + assert "light.kitchen: Kitchen" in data["prompt"] + + response = await client.request(method, "/api/powerllm/assist") + assert response.status == 200 + tools = {tool["name"] for tool in (await response.json())["tools"]} + assert "HassTurnOn" in tools + assert "GetDateTime" in tools + assert "HassGetState" not in tools + + response = await client.request(method, "/api/powerllm/non-existent") + assert response.status == 404 + + +@pytest.mark.usefixtures("mock_init_component") async def test_http_tool( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_admin_user: MockUser, - mock_init_component, + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: - """Test LLM Tool via HTTP API.""" - - class TestIntentHandler(intent.IntentHandler): - """Test Intent Handler.""" - - intent_type = "OrderBeer" - description = "Orders beer" - - @property - def slot_schema(self) -> dict | None: - """Return a slot schema.""" - return {vol.Required("type"): cv.string} - - async def async_handle(self, intent): - """Handle the intent.""" - assert intent.context.user_id == hass_admin_user.id - slots = self.async_validate_slots(intent.slots) - response = intent.create_response() - response.async_set_speech(f"I've ordered a {slots['type']['value']}!") - response.async_set_card( - "Beer ordered", f"You chose a {slots['type']['value']}." - ) - return response - - intent.async_register(hass, TestIntentHandler()) - + """HTTP state queries retain details and report invalid tools and arguments.""" + hass.states.async_set( + "light.kitchen", "on", {"friendly_name": "Kitchen", "brightness": 100} + ) + async_expose_entity(hass, "conversation", "light.kitchen", True) client = await hass_client() - resp = await client.post( - "/api/powerllm/assist/OrderBeer", + response = await client.post( + "/api/powerllm/powerllm/HassGetState", json={ "language": "en", - "device_id": "12345", - "tool_args": {"type": "Lager"}, + "tool_args": {"name": "Kitchen"}, }, ) + assert response.status == 200 + data = await response.json() + assert data["data"]["matched_states"] == [ + { + "entity_id": "light.kitchen", + "name": "Kitchen", + "state": "on", + "last_changed": "0 seconds ago", + "attributes": {"brightness": 100}, + } + ] - assert resp.status == 200 - data = await resp.json() + response = await client.post("/api/powerllm/assist/GetDateTime") + assert response.status == 200 + assert (await response.json())["success"] is True - assert data == { - "data": { - "failed": [], - "success": [], - "targets": [], - }, - "response_type": "action_done", - "speech": { - "plain": { - "extra_data": None, - "speech": "I've ordered a Lager!", - }, + response = await client.post("/api/powerllm/non-existent/non-existent") + assert response.status == 404 + response = await client.post("/api/powerllm/powerllm/HassTurnOn") + assert response.status == 404 + response = await client.post( + "/api/powerllm/powerllm/HassGetState", + json={ + "tool_args": {"unexpected": "value"}, }, - } - - resp = await client.post("/api/powerllm/non-existent/non-existent") - assert resp.status == 404 - - resp = await client.post("/api/powerllm/assist/non-existent") - assert resp.status == 404 - - resp = await client.post("/api/powerllm/assist/OrderBeer") - - assert resp.status == 500 - data = await resp.json() - - assert data == { - "error": "InvalidSlotInfo", - "error_text": "Received invalid slot info for OrderBeer", - } + ) + assert response.status == 500 + assert (await response.json())["error"] == "MultipleInvalid" diff --git a/tests/test_llm_tools.py b/tests/test_llm_tools.py index 0b7b1d6..5501934 100644 --- a/tests/test_llm_tools.py +++ b/tests/test_llm_tools.py @@ -11,10 +11,7 @@ ) from custom_components.powerllm import llm_tools - - -def test_test(hass): - """Workaround for https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/discussions/160.""" +from custom_components.powerllm.tools import get_state def test_format_state(hass: HomeAssistant) -> None: @@ -23,7 +20,7 @@ def test_format_state(hass: HomeAssistant) -> None: "light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} ) - assert llm_tools._format_state(hass, state1) == { + assert get_state._format_state(hass, state1) == { "name": "kitchen light", "entity_id": "light.kitchen", "state": "on", @@ -43,7 +40,7 @@ def test_format_state_with_attributes(hass: HomeAssistant) -> None: }, ) - assert llm_tools._format_state(hass, state1) == { + assert get_state._format_state(hass, state1) == { "name": "kitchen light", "entity_id": "light.kitchen", "state": "on", @@ -66,7 +63,7 @@ def test_format_state_with_alias( ) entity_registry.async_update_entity(state1.entity_id, aliases={"küchenlicht"}) - assert llm_tools._format_state(hass, state1) == { + assert get_state._format_state(hass, state1) == { "name": "kitchen light", "entity_id": "light.kitchen", "state": "on", @@ -90,7 +87,7 @@ def test_format_state_with_area( ) entity_registry.async_update_entity(state1.entity_id, area_id=area_kitchen.id) - assert llm_tools._format_state(hass, state1) == { + assert get_state._format_state(hass, state1) == { "name": "kitchen light", "entity_id": "light.kitchen", "state": "on", @@ -120,7 +117,7 @@ def test_format_state_with_floor( ) entity_registry.async_update_entity(state1.entity_id, area_id=area_kitchen.id) - assert llm_tools._format_state(hass, state1) == { + assert get_state._format_state(hass, state1) == { "name": "kitchen light", "entity_id": "light.kitchen", "state": "on", @@ -253,3 +250,24 @@ async def async_test_async_function( "platform": "test_platform", "required_arg": 4, } + + +def test_format_state_with_computed_alias( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Resolve Core's computed-name marker into a readable alias.""" + entry = entity_registry.async_get_or_create( + "light", + "demo", + "computed", + suggested_object_id="kitchen", + original_name="Kitchen light", + ) + entity_registry.async_update_entity( + entry.entity_id, aliases=[er.COMPUTED_NAME, "Kitchen lamp"] + ) + state = State(entry.entity_id, "on", {ATTR_FRIENDLY_NAME: "Kitchen light"}) + assert get_state._format_state(hass, state)["aliases"] == [ + "Kitchen light", + "Kitchen lamp", + ] diff --git a/tests/tools/test_get_state.py b/tests/tools/test_get_state.py new file mode 100644 index 0000000..b133deb --- /dev/null +++ b/tests/tools/test_get_state.py @@ -0,0 +1,239 @@ +"""Tests for detailed state queries.""" + +from unittest.mock import patch + +import pytest +import voluptuous as vol +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + floor_registry as fr, + intent, + llm, +) +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.powerllm.tools.get_state import GetStateTool + + +async def test_get_state_context( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, + mock_init_component, +) -> None: + """Keep detailed responses and pass the request's device context to the intent.""" + test_context = Context(user_id="12345") + llm_context = llm.LLMContext( + platform="test_platform", + context=test_context, + language="*", + assistant="conversation", + device_id=None, + ) + api = await llm.async_get_api(hass, "powerllm", llm_context) + assert test_context.json_fragment # To reproduce an error case in tracing + intent_response = intent.IntentResponse("*") + intent_response.async_set_states( + [State("light.matched", "on")], [State("light.unmatched", "on")] + ) + intent_response.async_set_speech("Some speech") + intent_response.async_set_card("Card title", "card content") + intent_response.async_set_speech_slots({"hello": 1}) + intent_response.async_set_reprompt("Do it again") + tool_input = llm.ToolInput( + tool_name="HassGetState", + tool_args={"area": "kitchen", "floor": "ground_floor"}, + ) + + with patch( + "homeassistant.helpers.intent.async_handle", return_value=intent_response + ) as mock_intent_handle: + response = await api.async_call_tool(tool_input) + + mock_intent_handle.assert_awaited_once_with( + hass=hass, + platform="test_platform", + intent_type="HassGetState", + slots={ + "area": {"value": "kitchen"}, + "floor": {"value": "ground_floor"}, + }, + text_input=None, + context=test_context, + language="*", + assistant="conversation", + device_id=None, + ) + assert response == { + "card": { + "simple": { + "content": "card content", + "title": "Card title", + }, + }, + "data": { + "matched_states": [ + { + "entity_id": "light.matched", + "last_changed": "0 seconds ago", + "name": "matched", + "state": "on", + }, + ], + "unmatched_states": [ + { + "entity_id": "light.unmatched", + "last_changed": "0 seconds ago", + "name": "unmatched", + "state": "on", + }, + ], + }, + "reprompt": { + "plain": { + "reprompt": "Do it again", + }, + }, + "response_type": "action_done", + "speech": { + "plain": { + "speech": "Some speech", + }, + }, + "speech_slots": { + "hello": 1, + }, + } + + # Call with a device/area/floor + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "1234")}, + suggested_area="Test Area", + ) + area = area_registry.async_get_area_by_name("Test Area") + floor = floor_registry.async_create("2") + area_registry.async_update(area.id, floor_id=floor.floor_id) + llm_context.device_id = device.id + + with patch( + "homeassistant.helpers.intent.async_handle", return_value=intent_response + ) as mock_intent_handle: + response = await api.async_call_tool(tool_input) + + mock_intent_handle.assert_awaited_once_with( + hass=hass, + platform="test_platform", + intent_type="HassGetState", + slots={ + "area": {"value": "kitchen"}, + "floor": {"value": "ground_floor"}, + "preferred_area_id": {"value": area.id}, + "preferred_floor_id": {"value": floor.floor_id}, + }, + text_input=None, + context=test_context, + language="*", + assistant="conversation", + device_id=device.id, + ) + assert response == { + "card": { + "simple": { + "content": "card content", + "title": "Card title", + }, + }, + "data": { + "matched_states": [ + { + "entity_id": "light.matched", + "last_changed": "0 seconds ago", + "name": "matched", + "state": "on", + }, + ], + "unmatched_states": [ + { + "entity_id": "light.unmatched", + "last_changed": "0 seconds ago", + "name": "unmatched", + "state": "on", + }, + ], + }, + "response_type": "action_done", + "reprompt": { + "plain": { + "reprompt": "Do it again", + }, + }, + "speech": { + "plain": { + "speech": "Some speech", + }, + }, + "speech_slots": { + "hello": 1, + }, + } + + +async def test_get_state_filters( + hass: HomeAssistant, llm_context: llm.LLMContext, mock_init_component +) -> None: + """Query actual exposed states and retain both matching and nonmatching details.""" + hass.states.async_set( + "light.on", "on", {"friendly_name": "On light", "brightness": 100} + ) + hass.states.async_set("light.off", "off", {"friendly_name": "Off light"}) + hass.states.async_set("light.hidden", "on", {"friendly_name": "Hidden light"}) + async_expose_entity(hass, "conversation", "light.on", True) + async_expose_entity(hass, "conversation", "light.off", True) + async_expose_entity(hass, "conversation", "light.hidden", False) + api = await llm.async_get_api(hass, "powerllm", llm_context) + result = await api.async_call_tool( + llm.ToolInput( + tool_name="HassGetState", tool_args={"domain": "light", "state": "on"} + ) + ) + assert result["response_type"] == "query_answer" + assert result["data"]["matched_states"] == [ + { + "name": "On light", + "entity_id": "light.on", + "state": "on", + "last_changed": "0 seconds ago", + "attributes": {"brightness": 100}, + } + ] + assert result["data"]["unmatched_states"] == [ + { + "name": "Off light", + "entity_id": "light.off", + "state": "off", + "last_changed": "0 seconds ago", + } + ] + assert "light.hidden" not in str(result) + + +async def test_get_state_invalid_args( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Reject unsupported arguments before calling the intent.""" + with pytest.raises(vol.Invalid): + await GetStateTool().async_call( + hass, + llm.ToolInput( + tool_name="HassGetState", tool_args={"preferred_area_id": "injected"} + ), + llm_context, + ) diff --git a/tests/tools/test_python_code.py b/tests/tools/test_python_code.py index 0d980da..9a936bd 100644 --- a/tests/tools/test_python_code.py +++ b/tests/tools/test_python_code.py @@ -1,10 +1,6 @@ """Test python script tool.""" -def test_test(hass): - """Workaround for https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/discussions/160.""" - - async def test_python_script_tool(async_call_tool) -> None: """Test python script tool.""" diff --git a/tests/tools/test_script.py b/tests/tools/test_script.py new file mode 100644 index 0000000..d92cb15 --- /dev/null +++ b/tests/tools/test_script.py @@ -0,0 +1,38 @@ +"""Tests for the entity ID mapping contributed by the script tool.""" + +import pytest +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.llm import LLMContext +from homeassistant.util.yaml import parse_yaml + +from custom_components.powerllm.tools.script import DynamicScriptTool + + +@pytest.mark.parametrize("exposed_only", [True, False]) +async def test_entity_mapping( + hass: HomeAssistant, llm_context: LLMContext, exposed_only: bool +) -> None: + """Preserve duplicate names and only disclose exposed identities, without states.""" + hass.states.async_set( + "light.first", "on", {"friendly_name": "Lamp", "brightness": 100} + ) + hass.states.async_set("light.second", "off", {"friendly_name": "Lamp"}) + hass.states.async_set("calendar.events", "off", {"friendly_name": "Calendar"}) + hass.states.async_set("script.evening", "off", {"friendly_name": "Evening"}) + hass.states.async_set("light.hidden", "on", {"friendly_name": "Hidden"}) + for entity_id in ( + "light.first", + "light.second", + "calendar.events", + "script.evening", + ): + async_expose_entity(hass, "conversation", entity_id, True) + async_expose_entity(hass, "conversation", "light.hidden", False) + prompt = DynamicScriptTool(exposed_only).prompt(hass, llm_context) + assert parse_yaml(prompt.split("\n", 1)[1]) == { + "light.first": "Lamp", + "light.second": "Lamp", + "calendar.events": "Calendar", + "script.evening": "Evening", + } diff --git a/tests/tools/test_web_scrape.py b/tests/tools/test_web_scrape.py index 4df590d..43d481c 100644 --- a/tests/tools/test_web_scrape.py +++ b/tests/tools/test_web_scrape.py @@ -3,10 +3,6 @@ from unittest.mock import MagicMock, patch -def test_test(hass): - """Workaround for https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/discussions/160.""" - - async def test_web_scrape_tool(async_call_tool) -> None: """Test web scrape tool."""