diff --git a/.core_files.yaml b/.core_files.yaml index ea08fd4a53cd..dd3fd828fcb0 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -95,6 +95,7 @@ components: &components - homeassistant/components/input_select/** - homeassistant/components/input_text/** - homeassistant/components/labs/** + - homeassistant/components/llm/** - homeassistant/components/logbook/** - homeassistant/components/logger/** - homeassistant/components/lovelace/** diff --git a/.strict-typing b/.strict-typing index 66075b93743b..97a0df52c58d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -347,6 +347,7 @@ homeassistant.components.light.* homeassistant.components.linkplay.* homeassistant.components.litejet.* homeassistant.components.litterrobot.* +homeassistant.components.llama_cpp.* homeassistant.components.local_ip.* homeassistant.components.local_todo.* homeassistant.components.lock.* diff --git a/CODEOWNERS b/CODEOWNERS index 2a13b7d1f6de..af40ccba5847 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1026,6 +1026,10 @@ CLAUDE.md @home-assistant/core /tests/components/litterrobot/ @natekspencer @tkdrob /homeassistant/components/livisi/ @StefanIacobLivisi @planbnet /tests/components/livisi/ @StefanIacobLivisi @planbnet +/homeassistant/components/llama_cpp/ @allenporter +/tests/components/llama_cpp/ @allenporter +/homeassistant/components/llm/ @home-assistant/core +/tests/components/llm/ @home-assistant/core /homeassistant/components/local_calendar/ @allenporter /tests/components/local_calendar/ @allenporter /homeassistant/components/local_ip/ @issacg diff --git a/homeassistant/components/airobot/button.py b/homeassistant/components/airobot/button.py index 24adef5dbcd1..46bb3847219c 100644 --- a/homeassistant/components/airobot/button.py +++ b/homeassistant/components/airobot/button.py @@ -32,6 +32,7 @@ class AirobotButtonEntityDescription(ButtonEntityDescription): """Describes Airobot button entity.""" press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]] + ignore_connection_errors: bool = False BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = ( @@ -40,6 +41,7 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = ( device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda coordinator: coordinator.client.reboot_thermostat(), + ignore_connection_errors=True, ), AirobotButtonEntityDescription( key="recalibrate_co2", @@ -84,10 +86,14 @@ class AirobotButton(AirobotEntity, ButtonEntity): """Handle the button press.""" try: await self.entity_description.press_fn(self.coordinator) - # pylint: disable-next=home-assistant-action-swallowed-exception - except AirobotConnectionError, AirobotTimeoutError: + except (AirobotConnectionError, AirobotTimeoutError) as err: + if not self.entity_description.ignore_connection_errors: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="button_press_failed", + translation_placeholders={"button": self.entity_description.key}, + ) from err # Connection errors during reboot are expected as device restarts - pass except AirobotError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index 9d89165809db..e154c3d1b662 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -219,6 +219,21 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> b entry, version=new_version, minor_version=new_minor_version ) + if entry.version == 2: + new_version = 3 + new_minor_version = 1 + new_data = {**entry.data} + + if "advanced_settings" in new_data: + new_data[SECTION_ADDITIONAL_SETTINGS] = new_data.pop("advanced_settings") + + hass.config_entries.async_update_entry( + entry, + data=new_data, + version=new_version, + minor_version=new_minor_version, + ) + return True diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index 0a59c721db10..291af1a499bd 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -90,7 +90,7 @@ STEP_MANUAL_DATA_SCHEMA = STEP_DISCOVERY_DATA_SCHEMA.extend( class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ubiquiti airOS.""" - VERSION = 2 + VERSION = 3 MINOR_VERSION = 1 _discovery_task: asyncio.Task | None = None diff --git a/homeassistant/components/airthings_ble/const.py b/homeassistant/components/airthings_ble/const.py index 43b6268bd093..195bd0e74cfa 100644 --- a/homeassistant/components/airthings_ble/const.py +++ b/homeassistant/components/airthings_ble/const.py @@ -5,9 +5,6 @@ from airthings_ble import AirthingsDeviceType DOMAIN = "airthings_ble" MFCT_ID = 820 -VOLUME_BECQUEREL = "Bq/m³" -VOLUME_PICOCURIE = "pCi/L" - DEVICE_MODEL = "device_model" DEFAULT_SCAN_INTERVAL = 300 diff --git a/homeassistant/components/airthings_ble/coordinator.py b/homeassistant/components/airthings_ble/coordinator.py index ca580483e373..7c7284f7e751 100644 --- a/homeassistant/components/airthings_ble/coordinator.py +++ b/homeassistant/components/airthings_ble/coordinator.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( DEFAULT_SCAN_INTERVAL, @@ -36,9 +35,7 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]): def __init__(self, hass: HomeAssistant, entry: AirthingsBLEConfigEntry) -> None: """Initialize the coordinator.""" - self.airthings = AirthingsBluetoothDeviceData( - _LOGGER, hass.config.units is METRIC_SYSTEM - ) + self.airthings = AirthingsBluetoothDeviceData(_LOGGER, is_metric=True) device_model = entry.data.get(DEVICE_MODEL) interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get( diff --git a/homeassistant/components/airthings_ble/icons.json b/homeassistant/components/airthings_ble/icons.json index 04b951999baf..600e0d3f6e77 100644 --- a/homeassistant/components/airthings_ble/icons.json +++ b/homeassistant/components/airthings_ble/icons.json @@ -9,15 +9,9 @@ "smartlink": "mdi:hub" } }, - "radon_1day_avg": { - "default": "mdi:radioactive" - }, "radon_1day_level": { "default": "mdi:radioactive" }, - "radon_longterm_avg": { - "default": "mdi:radioactive" - }, "radon_longterm_level": { "default": "mdi:radioactive" } diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index b707e8c2a921..afeaacc62f16 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -1,7 +1,6 @@ """Support for airthings ble sensors.""" from collections.abc import Callable -import dataclasses from dataclasses import dataclass import logging from typing import override @@ -19,6 +18,7 @@ from homeassistant.const import ( EntityCategory, Platform, UnitOfPressure, + UnitOfRadiationConcentration, UnitOfRatio, UnitOfSoundPressure, UnitOfTemperature, @@ -33,9 +33,8 @@ from homeassistant.helpers.entity_registry import ( ) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.unit_system import METRIC_SYSTEM -from .const import DOMAIN, VOLUME_BECQUEREL, VOLUME_PICOCURIE +from .const import DOMAIN from .coordinator import AirthingsBLEConfigEntry, AirthingsBLEDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -65,15 +64,15 @@ SENSORS_MAPPING_TEMPLATE: dict[str, AirthingsBLESensorEntityDescription] = { "radon_1day_avg": AirthingsBLESensorEntityDescription( key="radon_1day_avg", translation_key="radon_1day_avg", - native_unit_of_measurement=VOLUME_BECQUEREL, - suggested_display_precision=0, + device_class=SensorDeviceClass.RADON, + native_unit_of_measurement=UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), "radon_longterm_avg": AirthingsBLESensorEntityDescription( key="radon_longterm_avg", translation_key="radon_longterm_avg", - native_unit_of_measurement=VOLUME_BECQUEREL, - suggested_display_precision=0, + device_class=SensorDeviceClass.RADON, + native_unit_of_measurement=UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), "radon_1day_level": AirthingsBLESensorEntityDescription( @@ -210,26 +209,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Airthings BLE sensors.""" - is_metric = hass.config.units is METRIC_SYSTEM - coordinator = entry.runtime_data - # we need to change some units - sensors_mapping = SENSORS_MAPPING_TEMPLATE.copy() - if not is_metric: - for key, val in sensors_mapping.items(): - if val.native_unit_of_measurement is not VOLUME_BECQUEREL: - continue - sensors_mapping[key] = dataclasses.replace( - val, - native_unit_of_measurement=VOLUME_PICOCURIE, - suggested_display_precision=1, - ) - entities = [] _LOGGER.debug("got sensors: %s", coordinator.data.sensors) for sensor_type, sensor_value in coordinator.data.sensors.items(): - if sensor_type not in sensors_mapping: + if sensor_type not in SENSORS_MAPPING_TEMPLATE: _LOGGER.debug( "Unknown sensor type detected: %s, %s", sensor_type, @@ -238,7 +223,9 @@ async def async_setup_entry( continue async_migrate(hass, coordinator.data.address, sensor_type) entities.append( - AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type]) + AirthingsSensor( + coordinator, coordinator.data, SENSORS_MAPPING_TEMPLATE[sensor_type] + ) ) async_add_entities(entities) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 330a41dccf27..f8268b06bba1 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==14.1.8"] + "requirements": ["aioamazondevices==14.1.9"] } diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ded5f8e57be6..39b74b3fe3b1 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -46,6 +46,7 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, recorder, template +from homeassistant.helpers.http import MIN_COMPRESSED_RESPONSE_SIZE from homeassistant.helpers.json import json_dumps, json_fragment from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType @@ -223,12 +224,14 @@ class APIStatesView(HomeAssistantView): for state in hass.states.async_all() if entity_perm(state.entity_id, POLICY_READ) ) + body = b"".join((b"[", b",".join(states), b"]")) response = web.Response( - body=b"".join((b"[", b",".join(states), b"]")), + body=body, content_type=CONTENT_TYPE_JSON, zlib_executor_size=32768, ) - response.enable_compression() + if len(body) > MIN_COMPRESSED_RESPONSE_SIZE: + response.enable_compression() return response @@ -297,11 +300,12 @@ class APIEntityStateView(HomeAssistantView): return self.json_message( "Error storing state.", HTTPStatus.INTERNAL_SERVER_ERROR ) - resp = self.json(state.as_dict(), status_code) - - resp.headers.add("Location", f"/api/states/{entity_id}") - - return resp + return web.Response( + body=state.as_dict_json, + content_type=CONTENT_TYPE_JSON, + status=status_code, + headers={"Location": f"/api/states/{entity_id}"}, + ) @ha.callback def delete(self, request: web.Request, entity_id: str) -> web.Response: diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index a48d704141fc..70b66bf2b82c 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -1,4 +1,4 @@ -"""The aurora component.""" +"""The Aurora integration.""" from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index b6fb8df0f7ea..1b485f551f3b 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -1,4 +1,4 @@ -"""The aurora component.""" +"""The Aurora integration.""" from datetime import timedelta import logging diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index 317b82aed5a0..4403bdecd3d7 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -1,4 +1,4 @@ -"""The aurora component.""" +"""The Aurora integration.""" from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/calendar/llm.py b/homeassistant/components/calendar/llm.py new file mode 100644 index 000000000000..d92466377f58 --- /dev/null +++ b/homeassistant/components/calendar/llm.py @@ -0,0 +1,106 @@ +"""LLM tools for the calendar integration.""" + +from datetime import timedelta +from operator import attrgetter +from typing import cast, override + +import voluptuous as vol + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonObjectType + +from . import SERVICE_GET_EVENTS +from .const import DOMAIN + + +class CalendarGetEventsTool(Tool): + """LLM Tool allowing querying a calendar.""" + + name = "calendar_get_events" + description = ( + "Get events from a calendar. " + "When asked if something happens, search the whole week. " + "Results are RFC 5545 which means 'end' is exclusive." + ) + + def __init__(self, calendars: list[str]) -> None: + """Init the get events tool.""" + self.parameters = vol.Schema( + { + vol.Required("calendar"): vol.In(calendars), + vol.Required("range"): vol.In(["today", "week"]), + } + ) + + @override + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a calendar.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["calendar"], + domains=[DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "Calendar not found"} + + entity_id = result.states[0].entity_id + if data["range"] == "today": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=1) + elif data["range"] == "week": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=7) + + service_data = { + "entity_id": entity_id, + "start_date_time": start.isoformat(), + "end_date_time": end.isoformat(), + } + + service_result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_EVENTS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + + events = [ + event if "T" in event["start"] else {**event, "all_day": True} + for event in cast(dict, service_result)[entity_id]["events"] + ] + + return {"success": True, "result": events} + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return the calendar LLM tools when a calendar is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + entity_registry = er.async_get(hass) + names: list[str] = [] + for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")): + if not async_should_expose(hass, llm_context.assistant, state.entity_id): + continue + entity_entry = entity_registry.async_get(state.entity_id) + names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state)) + + if not names: + return None + return LLMTools(tools=[CalendarGetEventsTool(names)]) diff --git a/homeassistant/components/cielo_home/climate.py b/homeassistant/components/cielo_home/climate.py index 38ddd9cbf2df..37d315c108ef 100644 --- a/homeassistant/components/cielo_home/climate.py +++ b/homeassistant/components/cielo_home/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -103,28 +103,6 @@ class CieloClimate(CieloDeviceEntity, ClimateEntity): super().__init__(coordinator, device_id) self._attr_unique_id = device_id - @property - @override - def temperature_unit(self) -> str: - """Return the unit of temperature in Home Assistant format. - - It can change over time based on the device settings, - so we fetch it dynamically from the client. - """ - unit = self.client.temperature_unit() - - if not unit: - return UnitOfTemperature.CELSIUS - - normalized = unit.strip().lower() - - if normalized in {"c", "°c", "celsius"}: - return UnitOfTemperature.CELSIUS - if normalized in {"f", "°f", "fahrenheit"}: - return UnitOfTemperature.FAHRENHEIT - - return UnitOfTemperature.CELSIUS - @property @override def supported_features(self) -> ClimateEntityFeature: diff --git a/homeassistant/components/cielo_home/const.py b/homeassistant/components/cielo_home/const.py index dbc3d68d342a..1818f472b5c0 100644 --- a/homeassistant/components/cielo_home/const.py +++ b/homeassistant/components/cielo_home/const.py @@ -11,12 +11,16 @@ from homeassistant.const import Platform DOMAIN: Final = "cielo_home" PLATFORMS: Final[list[Platform]] = [ Platform.CLIMATE, + Platform.SENSOR, ] DEFAULT_NAME: Final = "Cielo Home" DEFAULT_SCAN_INTERVAL: Final[int] = 2 * 60 TIMEOUT: Final[int] = 20 LOGGER: Final = logging.getLogger(__package__) +SENSOR_TEMPERATURE: Final = "temperature" +SENSOR_HUMIDITY: Final = "humidity" + CIELO_ERRORS: Final[tuple] = ( ClientError, TimeoutError, diff --git a/homeassistant/components/cielo_home/entity.py b/homeassistant/components/cielo_home/entity.py index dfe2e9760440..fdceea79f40f 100644 --- a/homeassistant/components/cielo_home/entity.py +++ b/homeassistant/components/cielo_home/entity.py @@ -5,6 +5,7 @@ from typing import override from cieloconnectapi.device import CieloDeviceAPI from cieloconnectapi.model import CieloDevice +from homeassistant.const import UnitOfTemperature from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -12,6 +13,26 @@ from .const import DOMAIN from .coordinator import CieloDataUpdateCoordinator +def normalize_temp_unit(client: CieloDeviceAPI) -> str: + """Normalize a raw device temperature unit to a UnitOfTemperature value. + + Unrecognized or empty values fall back to Celsius. + """ + unit = client.temperature_unit() + + if not unit: + return UnitOfTemperature.CELSIUS + + normalized = unit.strip().lower() + + if normalized in {"c", "°c", "celsius"}: + return UnitOfTemperature.CELSIUS + if normalized in {"f", "°f", "fahrenheit"}: + return UnitOfTemperature.FAHRENHEIT + + return UnitOfTemperature.CELSIUS + + class CieloBaseEntity(CoordinatorEntity[CieloDataUpdateCoordinator]): """Representation of a Cielo base entity.""" @@ -74,3 +95,14 @@ class CieloDeviceEntity(CieloBaseEntity): configuration_url="https://home.cielowigle.com/", suggested_area=device.name, ) + + @property + def temperature_unit(self) -> str: + """Return the unit of temperature for the device. + + The unit can change over time based on the device settings, + so it is fetched dynamically from the client. This dynamic + nature means that if a user changes the device's temperature + unit, historical statistics may be affected. + """ + return normalize_temp_unit(self.client) diff --git a/homeassistant/components/cielo_home/sensor.py b/homeassistant/components/cielo_home/sensor.py new file mode 100644 index 000000000000..b1cfe14e24d9 --- /dev/null +++ b/homeassistant/components/cielo_home/sensor.py @@ -0,0 +1,101 @@ +"""Support for Cielo Home sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from cieloconnectapi.device import CieloDeviceAPI +from cieloconnectapi.model import CieloDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import SENSOR_HUMIDITY, SENSOR_TEMPERATURE +from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry +from .entity import CieloDeviceEntity, normalize_temp_unit + + +@dataclass(kw_only=True, frozen=True) +class CieloSensorEntityDescription(SensorEntityDescription): + """Describes a Cielo Home sensor entity.""" + + value_fn: Callable[[CieloDeviceAPI, CieloDevice | None], float | int | None] + unit_fn: Callable[[CieloDeviceAPI], str | None] | None = None + + +SENSOR_DESCRIPTIONS: tuple[CieloSensorEntityDescription, ...] = ( + CieloSensorEntityDescription( + key=SENSOR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda client, device_data: client.current_temperature(), + # Temperature unit is dynamic; see the native_unit_of_measurement property for limitations. + unit_fn=normalize_temp_unit, + ), + CieloSensorEntityDescription( + key=SENSOR_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda client, device_data: ( + device_data.humidity if device_data else None + ), + ), +) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CieloHomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Cielo Home sensors.""" + coordinator = entry.runtime_data + + entities = [ + CieloSensor(coordinator, device_id, description) + for device_id in coordinator.data.parsed + for description in SENSOR_DESCRIPTIONS + ] + async_add_entities(entities) + + +class CieloSensor(CieloDeviceEntity, SensorEntity): + """Representation of a Cielo Home sensor.""" + + entity_description: CieloSensorEntityDescription + + def __init__( + self, + coordinator: CieloDataUpdateCoordinator, + device_id: str, + entity_description: CieloSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id) + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}-{entity_description.key}" + + @property + @override + def native_value(self) -> float | int | None: + """Return the native value of the sensor.""" + return self.entity_description.value_fn(self.client, self.device_data) + + @property + @override + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of measurement.""" + if self.entity_description.unit_fn is not None: + return self.entity_description.unit_fn(self.client) + return super().native_unit_of_measurement diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index f0fafd413c88..afb4bb43daed 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, override import voluptuous as vol -from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature +from homeassistant.const import CONF_OPTIONS, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec @@ -19,7 +19,7 @@ from homeassistant.helpers.condition import ( ) from homeassistant.util.unit_conversion import TemperatureConverter -from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode +from .const import DOMAIN, ClimateEntityStateAttribute, HVACAction, HVACMode CONF_HVAC_MODE = "hvac_mode" @@ -57,7 +57,9 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): """Mixin for climate target temperature conditions with unit conversion.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.TEMPERATURE) + } _unit_converter = TemperatureConverter @override @@ -65,7 +67,8 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): """Skip climate entities that do not expose a target temperature.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_TEMPERATURE) is not None + and state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE) + is not None ) @override @@ -78,7 +81,9 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): class ClimateTargetHumidityCondition(EntityNumericalConditionBase): """Condition for climate target humidity.""" - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HUMIDITY) + } _valid_unit = "%" @override @@ -86,7 +91,7 @@ class ClimateTargetHumidityCondition(EntityNumericalConditionBase): """Skip climate entities that do not expose a target humidity.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_HUMIDITY) is not None + and state.attributes.get(ClimateEntityStateAttribute.HUMIDITY) is not None ) @@ -105,13 +110,16 @@ CONDITIONS: dict[str, type[Condition]] = { }, ), "is_cooling": make_entity_state_condition( - {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING + {DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)}, + HVACAction.COOLING, ), "is_drying": make_entity_state_condition( - {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING + {DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)}, + HVACAction.DRYING, ), "is_heating": make_entity_state_condition( - {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING + {DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)}, + HVACAction.HEATING, ), "is_target_humidity": ClimateTargetHumidityCondition, "is_target_temperature": ClimateTargetTemperatureCondition, diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index a52cdc15e79e..a5eed75138d2 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -110,7 +110,12 @@ async def async_get_action_capabilities( try: entry = async_get_entity_registry_entry_or_raise(hass, entity_id_or_uuid) hvac_modes = ( - get_capability(hass, entry.entity_id, const.ATTR_HVAC_MODES) or [] + get_capability( + hass, + entry.entity_id, + const.ClimateEntityCapabilityAttribute.HVAC_MODES, + ) + or [] ) except HomeAssistantError: hvac_modes = [] @@ -119,7 +124,12 @@ async def async_get_action_capabilities( try: entry = async_get_entity_registry_entry_or_raise(hass, entity_id_or_uuid) preset_modes = ( - get_capability(hass, entry.entity_id, const.ATTR_PRESET_MODES) or [] + get_capability( + hass, + entry.entity_id, + const.ClimateEntityCapabilityAttribute.PRESET_MODES, + ) + or [] ) except HomeAssistantError: preset_modes = [] diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index 11b48639f39f..1ff5ee7c955f 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -94,7 +94,7 @@ def async_condition_from_config( return bool(state.state == config[const.ATTR_HVAC_MODE]) return bool( - state.attributes.get(const.ATTR_PRESET_MODE) + state.attributes.get(const.ClimateEntityStateAttribute.PRESET_MODE) == config[const.ATTR_PRESET_MODE] ) @@ -115,7 +115,12 @@ async def async_get_condition_capabilities( hass, config[CONF_ENTITY_ID] ) hvac_modes = ( - get_capability(hass, entry.entity_id, const.ATTR_HVAC_MODES) or [] + get_capability( + hass, + entry.entity_id, + const.ClimateEntityCapabilityAttribute.HVAC_MODES, + ) + or [] ) except HomeAssistantError: hvac_modes = [] @@ -127,7 +132,12 @@ async def async_get_condition_capabilities( hass, config[CONF_ENTITY_ID] ) preset_modes = ( - get_capability(hass, entry.entity_id, const.ATTR_PRESET_MODES) or [] + get_capability( + hass, + entry.entity_id, + const.ClimateEntityCapabilityAttribute.PRESET_MODES, + ) + or [] ) except HomeAssistantError: preset_modes = [] diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index dab54f4d17b9..275a429fdc89 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -87,7 +87,11 @@ async def async_get_triggers( } ) - if state and const.ATTR_CURRENT_TEMPERATURE in state.attributes: + if ( + state + and const.ClimateEntityStateAttribute.CURRENT_TEMPERATURE + in state.attributes + ): triggers.append( { **base_trigger, @@ -95,7 +99,10 @@ async def async_get_triggers( } ) - if state and const.ATTR_CURRENT_HUMIDITY in state.attributes: + if ( + state + and const.ClimateEntityStateAttribute.CURRENT_HUMIDITY in state.attributes + ): triggers.append( { **base_trigger, diff --git a/homeassistant/components/climate/llm.py b/homeassistant/components/climate/llm.py new file mode 100644 index 000000000000..31a8e3f1e5c0 --- /dev/null +++ b/homeassistant/components/climate/llm.py @@ -0,0 +1,37 @@ +"""LLM tools for the climate integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import DOMAIN, INTENT_SET_TEMPERATURE + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_SET_TEMPERATURE,) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 9bf2131d6648..31ad9d874c43 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/reproduce_state.py @@ -25,8 +25,21 @@ from .const import ( SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + ClimateEntityStateAttribute, ) +# Maps a state attribute to the service call argument used to restore it. +_STATE_ATTRIBUTE_TO_SERVICE_ARG: dict[ClimateEntityStateAttribute, str] = { + ClimateEntityStateAttribute.TEMPERATURE: ATTR_TEMPERATURE, + ClimateEntityStateAttribute.TARGET_TEMP_HIGH: ATTR_TARGET_TEMP_HIGH, + ClimateEntityStateAttribute.TARGET_TEMP_LOW: ATTR_TARGET_TEMP_LOW, + ClimateEntityStateAttribute.PRESET_MODE: ATTR_PRESET_MODE, + ClimateEntityStateAttribute.SWING_MODE: ATTR_SWING_MODE, + ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE: ATTR_SWING_HORIZONTAL_MODE, + ClimateEntityStateAttribute.FAN_MODE: ATTR_FAN_MODE, + ClimateEntityStateAttribute.HUMIDITY: ATTR_HUMIDITY, +} + async def _async_reproduce_states( hass: HomeAssistant, @@ -38,14 +51,16 @@ async def _async_reproduce_states( """Reproduce component states.""" async def call_service( - service: str, keys: Iterable, data: dict[str, Any] | None = None + service: str, + attributes: Iterable[ClimateEntityStateAttribute], + data: dict[str, Any] | None = None, ) -> None: - """Call service with set of attributes given.""" + """Call service with the given state attributes.""" data = data or {} data["entity_id"] = state.entity_id - for key in keys: - if (value := state.attributes.get(key)) is not None: - data[key] = value + for attribute in attributes: + if (value := state.attributes.get(attribute)) is not None: + data[_STATE_ATTRIBUTE_TO_SERVICE_ARG[attribute]] = value await hass.services.async_call( DOMAIN, service, data, blocking=True, context=context @@ -55,43 +70,54 @@ async def _async_reproduce_states( await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state}) if ( - (state.attributes.get(ATTR_TEMPERATURE) is not None) - or (state.attributes.get(ATTR_TARGET_TEMP_HIGH) is not None) - or (state.attributes.get(ATTR_TARGET_TEMP_LOW) is not None) + state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE) is not None + or state.attributes.get(ClimateEntityStateAttribute.TARGET_TEMP_HIGH) + is not None + or state.attributes.get(ClimateEntityStateAttribute.TARGET_TEMP_LOW) is not None ): await call_service( SERVICE_SET_TEMPERATURE, - [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW], + [ + ClimateEntityStateAttribute.TEMPERATURE, + ClimateEntityStateAttribute.TARGET_TEMP_HIGH, + ClimateEntityStateAttribute.TARGET_TEMP_LOW, + ], ) if ( - ATTR_PRESET_MODE in state.attributes - and state.attributes[ATTR_PRESET_MODE] is not None - ): - await call_service(SERVICE_SET_PRESET_MODE, [ATTR_PRESET_MODE]) - - if ( - ATTR_SWING_MODE in state.attributes - and state.attributes[ATTR_SWING_MODE] is not None - ): - await call_service(SERVICE_SET_SWING_MODE, [ATTR_SWING_MODE]) - - if ( - ATTR_SWING_HORIZONTAL_MODE in state.attributes - and state.attributes[ATTR_SWING_HORIZONTAL_MODE] is not None + ClimateEntityStateAttribute.PRESET_MODE in state.attributes + and state.attributes[ClimateEntityStateAttribute.PRESET_MODE] is not None ): await call_service( - SERVICE_SET_SWING_HORIZONTAL_MODE, [ATTR_SWING_HORIZONTAL_MODE] + SERVICE_SET_PRESET_MODE, [ClimateEntityStateAttribute.PRESET_MODE] ) if ( - ATTR_FAN_MODE in state.attributes - and state.attributes[ATTR_FAN_MODE] is not None + ClimateEntityStateAttribute.SWING_MODE in state.attributes + and state.attributes[ClimateEntityStateAttribute.SWING_MODE] is not None ): - await call_service(SERVICE_SET_FAN_MODE, [ATTR_FAN_MODE]) + await call_service( + SERVICE_SET_SWING_MODE, [ClimateEntityStateAttribute.SWING_MODE] + ) - if ATTR_HUMIDITY in state.attributes: - await call_service(SERVICE_SET_HUMIDITY, [ATTR_HUMIDITY]) + if ( + ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE in state.attributes + and state.attributes[ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE] + is not None + ): + await call_service( + SERVICE_SET_SWING_HORIZONTAL_MODE, + [ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE], + ) + + if ( + ClimateEntityStateAttribute.FAN_MODE in state.attributes + and state.attributes[ClimateEntityStateAttribute.FAN_MODE] is not None + ): + await call_service(SERVICE_SET_FAN_MODE, [ClimateEntityStateAttribute.FAN_MODE]) + + if ClimateEntityStateAttribute.HUMIDITY in state.attributes: + await call_service(SERVICE_SET_HUMIDITY, [ClimateEntityStateAttribute.HUMIDITY]) async def async_reproduce_states( diff --git a/homeassistant/components/climate/significant_change.py b/homeassistant/components/climate/significant_change.py index 3b98c9c2fc00..39b0138bf784 100644 --- a/homeassistant/components/climate/significant_change.py +++ b/homeassistant/components/climate/significant_change.py @@ -9,32 +9,20 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from . import ( - ATTR_CURRENT_HUMIDITY, - ATTR_CURRENT_TEMPERATURE, - ATTR_FAN_MODE, - ATTR_HUMIDITY, - ATTR_HVAC_ACTION, - ATTR_PRESET_MODE, - ATTR_SWING_HORIZONTAL_MODE, - ATTR_SWING_MODE, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, -) +from . import ClimateEntityStateAttribute SIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_CURRENT_HUMIDITY, - ATTR_CURRENT_TEMPERATURE, - ATTR_FAN_MODE, - ATTR_HUMIDITY, - ATTR_HVAC_ACTION, - ATTR_PRESET_MODE, - ATTR_SWING_MODE, - ATTR_SWING_HORIZONTAL_MODE, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, + ClimateEntityStateAttribute.CURRENT_HUMIDITY, + ClimateEntityStateAttribute.CURRENT_TEMPERATURE, + ClimateEntityStateAttribute.FAN_MODE, + ClimateEntityStateAttribute.HUMIDITY, + ClimateEntityStateAttribute.HVAC_ACTION, + ClimateEntityStateAttribute.PRESET_MODE, + ClimateEntityStateAttribute.SWING_MODE, + ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE, + ClimateEntityStateAttribute.TARGET_TEMP_HIGH, + ClimateEntityStateAttribute.TARGET_TEMP_LOW, + ClimateEntityStateAttribute.TEMPERATURE, } @@ -63,11 +51,11 @@ def async_check_significant_change( for attr_name in changed_attrs: if attr_name in [ - ATTR_FAN_MODE, - ATTR_HVAC_ACTION, - ATTR_PRESET_MODE, - ATTR_SWING_MODE, - ATTR_SWING_HORIZONTAL_MODE, + ClimateEntityStateAttribute.FAN_MODE, + ClimateEntityStateAttribute.HVAC_ACTION, + ClimateEntityStateAttribute.PRESET_MODE, + ClimateEntityStateAttribute.SWING_MODE, + ClimateEntityStateAttribute.SWING_HORIZONTAL_MODE, ]: return True @@ -83,17 +71,20 @@ def async_check_significant_change( absolute_change: float | None = None if attr_name in [ - ATTR_CURRENT_TEMPERATURE, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, + ClimateEntityStateAttribute.CURRENT_TEMPERATURE, + ClimateEntityStateAttribute.TARGET_TEMP_HIGH, + ClimateEntityStateAttribute.TARGET_TEMP_LOW, + ClimateEntityStateAttribute.TEMPERATURE, ]: if ha_unit == UnitOfTemperature.FAHRENHEIT: absolute_change = 1.0 else: absolute_change = 0.5 - if attr_name in [ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY]: + if attr_name in [ + ClimateEntityStateAttribute.CURRENT_HUMIDITY, + ClimateEntityStateAttribute.HUMIDITY, + ]: absolute_change = 1.0 if absolute_change and check_absolute_change( diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index f791d8127436..d721951f9857 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -4,7 +4,7 @@ from typing import override import voluptuous as vol -from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature +from homeassistant.const import CONF_OPTIONS, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec @@ -24,7 +24,7 @@ from homeassistant.helpers.trigger import ( ) from homeassistant.util.unit_conversion import TemperatureConverter -from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode +from .const import DOMAIN, ClimateEntityStateAttribute, HVACAction, HVACMode CONF_HVAC_MODE = "hvac_mode" @@ -55,7 +55,9 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB """Mixin for climate target temperature triggers with unit conversion.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.TEMPERATURE) + } _unit_converter = TemperatureConverter @override @@ -63,7 +65,8 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB """Skip climate entities that do not expose a target temperature.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_TEMPERATURE) is not None + and state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE) + is not None ) @override @@ -90,7 +93,9 @@ class ClimateTargetTemperatureCrossedThresholdTrigger( class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase): """Mixin for climate target humidity triggers.""" - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HUMIDITY) + } _valid_unit = "%" @override @@ -98,7 +103,7 @@ class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase): """Skip climate entities that do not expose a target humidity.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_HUMIDITY) is not None + and state.attributes.get(ClimateEntityStateAttribute.HUMIDITY) is not None ) @@ -117,10 +122,12 @@ class ClimateTargetHumidityCrossedThresholdTrigger( TRIGGERS: dict[str, type[Trigger]] = { "hvac_mode_changed": HVACModeChangedTrigger, "started_cooling": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING + {DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)}, + HVACAction.COOLING, ), "started_drying": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING + {DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)}, + HVACAction.DRYING, ), "target_humidity_changed": ClimateTargetHumidityChangedTrigger, "target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger, @@ -144,7 +151,8 @@ TRIGGERS: dict[str, type[Trigger]] = { }, ), "started_heating": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING + {DOMAIN: DomainSpec(value_source=ClimateEntityStateAttribute.HVAC_ACTION)}, + HVACAction.HEATING, ), } diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index db83ac2b2297..d8824519be01 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -77,6 +77,7 @@ __all__ = [ "CoverEntity", "CoverEntityDescription", "CoverEntityFeature", + "CoverEntityStateAttribute", "CoverState", "make_cover_closed_trigger", "make_cover_is_closed_condition", diff --git a/homeassistant/components/cover/condition.py b/homeassistant/components/cover/condition.py index 95934ef7f24b..fccac350880a 100644 --- a/homeassistant/components/cover/condition.py +++ b/homeassistant/components/cover/condition.py @@ -7,7 +7,7 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.helpers.condition import Condition, EntityConditionBase -from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass +from .const import DOMAIN, CoverDeviceClass, CoverEntityStateAttribute from .models import CoverDomainSpec @@ -39,7 +39,9 @@ def make_cover_is_open_condition( _domain_specs = { domain: CoverDomainSpec( device_class=dc, - value_source=ATTR_IS_CLOSED if domain == DOMAIN else None, + value_source=( + CoverEntityStateAttribute.IS_CLOSED if domain == DOMAIN else None + ), target_value=False if domain == DOMAIN else STATE_ON, ) for domain, dc in device_classes.items() @@ -59,7 +61,9 @@ def make_cover_is_closed_condition( _domain_specs = { domain: CoverDomainSpec( device_class=dc, - value_source=ATTR_IS_CLOSED if domain == DOMAIN else None, + value_source=( + CoverEntityStateAttribute.IS_CLOSED if domain == DOMAIN else None + ), target_value=True if domain == DOMAIN else STATE_OFF, ) for domain, dc in device_classes.items() diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index b2198701d84a..8c3f7b854989 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -21,7 +21,7 @@ from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN, CoverEntityFeature, CoverState +from . import DOMAIN, CoverEntityFeature, CoverEntityStateAttribute, CoverState # mypy: disallow-any-generics @@ -137,9 +137,9 @@ def async_condition_from_config( return test_is_state if config[CONF_TYPE] == "is_position": - position_attr = "current_position" + position_attr = CoverEntityStateAttribute.CURRENT_POSITION if config[CONF_TYPE] == "is_tilt_position": - position_attr = "current_tilt_position" + position_attr = CoverEntityStateAttribute.CURRENT_TILT_POSITION min_pos = config.get(CONF_ABOVE) max_pos = config.get(CONF_BELOW) diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index 25b95ae6ef35..a7cd60b21834 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN, CoverEntityFeature, CoverState +from . import DOMAIN, CoverEntityFeature, CoverEntityStateAttribute, CoverState POSITION_TRIGGER_TYPES = {"position", "tilt_position"} STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} @@ -164,9 +164,9 @@ async def async_attach_trigger( ) if config[CONF_TYPE] == "position": - position = "current_position" + position = CoverEntityStateAttribute.CURRENT_POSITION if config[CONF_TYPE] == "tilt_position": - position = "current_tilt_position" + position = CoverEntityStateAttribute.CURRENT_TILT_POSITION min_pos = config.get(CONF_ABOVE, -1) max_pos = config.get(CONF_BELOW, 101) value_template = f"{{{{ state.attributes.{position} }}}}" diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py index ea7f3ef1f22d..1079673a0ac2 100644 --- a/homeassistant/components/cover/reproduce_state.py +++ b/homeassistant/components/cover/reproduce_state.py @@ -8,24 +8,23 @@ from typing import Any, Final from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, + EntityStateAttribute, ) from homeassistant.core import Context, HomeAssistant, ServiceResponse, State from homeassistant.util.enum import try_parse_enum from . import ( - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, CoverEntityFeature, + CoverEntityStateAttribute, CoverState, ) @@ -43,13 +42,13 @@ FULL_CLOSE: Final = 0 def _determine_features(current_attrs: dict[str, Any]) -> CoverEntityFeature: """Determine supported features based on current attributes.""" features = CoverEntityFeature(0) - if ATTR_CURRENT_POSITION in current_attrs: + if CoverEntityStateAttribute.CURRENT_POSITION in current_attrs: features |= ( CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if ATTR_CURRENT_TILT_POSITION in current_attrs: + if CoverEntityStateAttribute.CURRENT_TILT_POSITION in current_attrs: features |= ( CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.OPEN_TILT @@ -183,12 +182,20 @@ async def _async_reproduce_state( current_attrs = cur_state.attributes target_attrs = state.attributes - current_position: int | None = current_attrs.get(ATTR_CURRENT_POSITION) - target_position: int | None = target_attrs.get(ATTR_CURRENT_POSITION) + current_position: int | None = current_attrs.get( + CoverEntityStateAttribute.CURRENT_POSITION + ) + target_position: int | None = target_attrs.get( + CoverEntityStateAttribute.CURRENT_POSITION + ) position_matches = current_position == target_position - current_tilt_position: int | None = current_attrs.get(ATTR_CURRENT_TILT_POSITION) - target_tilt_position: int | None = target_attrs.get(ATTR_CURRENT_TILT_POSITION) + current_tilt_position: int | None = current_attrs.get( + CoverEntityStateAttribute.CURRENT_TILT_POSITION + ) + target_tilt_position: int | None = target_attrs.get( + CoverEntityStateAttribute.CURRENT_TILT_POSITION + ) tilt_position_matches = current_tilt_position == target_tilt_position state_matches = cur_state.state == target_state @@ -197,7 +204,7 @@ async def _async_reproduce_state( return features = try_parse_enum( - CoverEntityFeature, current_attrs.get(ATTR_SUPPORTED_FEATURES) + CoverEntityFeature, current_attrs.get(EntityStateAttribute.SUPPORTED_FEATURES) ) if features is None: # Backwards compatibility for integrations that diff --git a/homeassistant/components/cover/significant_change.py b/homeassistant/components/cover/significant_change.py index c1a860afd19e..7ce70e64a939 100644 --- a/homeassistant/components/cover/significant_change.py +++ b/homeassistant/components/cover/significant_change.py @@ -8,11 +8,11 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from . import ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION +from .const import CoverEntityStateAttribute SIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, + CoverEntityStateAttribute.CURRENT_POSITION, + CoverEntityStateAttribute.CURRENT_TILT_POSITION, } diff --git a/homeassistant/components/cover/trigger.py b/homeassistant/components/cover/trigger.py index 5c74e6ee7b12..bdfcc1f40665 100644 --- a/homeassistant/components/cover/trigger.py +++ b/homeassistant/components/cover/trigger.py @@ -11,7 +11,7 @@ from homeassistant.helpers.trigger import ( Trigger, ) -from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass +from .const import DOMAIN, CoverDeviceClass, CoverEntityStateAttribute from .models import CoverDomainSpec @@ -56,7 +56,9 @@ def make_cover_opened_trigger( _domain_specs = { domain: CoverDomainSpec( device_class=dc, - value_source=ATTR_IS_CLOSED if domain == DOMAIN else None, + value_source=( + CoverEntityStateAttribute.IS_CLOSED if domain == DOMAIN else None + ), target_value=False if domain == DOMAIN else STATE_ON, ) for domain, dc in device_classes.items() @@ -76,7 +78,9 @@ def make_cover_closed_trigger( _domain_specs = { domain: CoverDomainSpec( device_class=dc, - value_source=ATTR_IS_CLOSED if domain == DOMAIN else None, + value_source=( + CoverEntityStateAttribute.IS_CLOSED if domain == DOMAIN else None + ), target_value=True if domain == DOMAIN else STATE_OFF, ) for domain, dc in device_classes.items() diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index df35a749ee20..f0731f8f0a78 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -407,9 +407,9 @@ class DemoTVShowPlayer(AbstractDemoPlayer): class DemoBrowsePlayer(AbstractDemoPlayer): - """A Demo media player that supports browse.""" + """A Demo media player that supports browse and search.""" - _attr_supported_features = BROWSE_PLAYER_SUPPORT + _attr_supported_features = BROWSE_PLAYER_SUPPORT | SEARCH_PLAYER_SUPPORT @override async def async_browse_media( @@ -421,6 +421,13 @@ class DemoBrowsePlayer(AbstractDemoPlayer): return await media_source.async_browse_media(self.hass, media_content_id) + @override + async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia: + """Implement the websocket media search helper by delegating to media source.""" + return await media_source.async_search_media( + self.hass, query.media_content_id, query + ) + class DemoGroupPlayer(AbstractDemoPlayer): """A Demo media player that supports grouping.""" diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 27b8d48c0581..534e64ae5a7a 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -6,12 +6,14 @@ from typing import Any, override import evohomeasync2 as evo from evohomeasync2.const import ( + SZ_DURATION, + SZ_MODE, + SZ_PERIOD, SZ_SETPOINT_STATUS, SZ_SYSTEM_MODE, SZ_SYSTEM_MODE_STATUS, SZ_TEMPERATURE_STATUS, -) -from evohomeasync2.schemas.const import ( + SZ_UNTIL, SystemMode as EvoSystemMode, ZoneMode as EvoZoneMode, ) @@ -25,12 +27,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ( - ATTR_MODE, - ATTR_TEMPERATURE, - PRECISION_TENTHS, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -38,14 +35,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import ( - ATTR_DURATION, - ATTR_PERIOD, - DOMAIN, - EVOHOME_DATA, - RESET_BREAKS_IN_HA_VERSION, - EvoService, -) +from .const import DOMAIN, EVOHOME_DATA, RESET_BREAKS_IN_HA_VERSION, EvoService from .coordinator import EvoDataUpdateCoordinator from .entity import EvoChild, EvoEntity, is_valid_zone, unique_zone_id from .helpers import async_create_deprecation_issue_once @@ -272,7 +262,7 @@ class EvoZone(EvoChild, EvoClimateEntity): temperature = kwargs[ATTR_TEMPERATURE] - if (until := kwargs.get("until")) is None: + if (until := kwargs.get(SZ_UNTIL)) is None: if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE: until = self._evo_device.until if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE: @@ -397,14 +387,14 @@ class EvoController(EvoClimateEntity): await self.coordinator.call_client_api(self._evo_device.reset()) return - mode = data[ATTR_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE + mode = data[SZ_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE - if ATTR_PERIOD in data: + if SZ_PERIOD in data: until = dt_util.start_of_local_day() - until += data[ATTR_PERIOD] + until += data[SZ_PERIOD] - elif ATTR_DURATION in data: - until = dt_util.now() + data[ATTR_DURATION] + elif SZ_DURATION in data: + until = dt_util.now() + data[SZ_DURATION] else: until = None diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 06baf09cfc4c..3023b9081455 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -20,10 +20,6 @@ CONF_LOCATION_IDX: Final = "location_idx" SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60) -ATTR_DURATION: Final = "duration" # number of minutes, <24h -ATTR_PERIOD: Final = "period" # number of days -ATTR_SETPOINT: Final = "setpoint" - # Support for the refresh_system service is being deprecated REFRESH_BREAKS_IN_HA_VERSION: Final = "2027.1.0" # Support for the reset service calls/presets is being deprecated diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 3da5b8b15a1c..157ae189be5e 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -20,7 +20,7 @@ from evohomeasync2.const import ( SZ_USE_DAYLIGHT_SAVE_SWITCHING, SZ_ZONES, ) -from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT +from evohomeasync2.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index fb1d5dd3f882..3435bf0e19b2 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -1,15 +1,20 @@ """Support for entities of the Evohome integration.""" from collections.abc import Mapping +from datetime import datetime +from enum import StrEnum import logging from typing import Any, override import evohomeasync2 as evo -from evohomeasync2.schemas.const import ( +from evohomeasync2.const import ( + SZ_SINCE, + SZ_TIME_UNTIL, + SZ_UNTIL, ZoneModelType as EvoZoneModelType, ZoneType as EvoZoneType, ) -from evohomeasync2.schemas.typedefs import DayOfWeekDhwT +from evohomeasync2.typedefs import EvoDayOfWeekDhwT from homeassistant.core import callback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -20,6 +25,19 @@ from .coordinator import EvoDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +def _recurse_and_revert(val: Any, _key: str | None = None) -> Any: + """Recursively revert any values to native format.""" + if isinstance(val, dict): + return {k: _recurse_and_revert(v, k) for k, v in val.items()} + if isinstance(val, (list, tuple)): + return type(val)(_recurse_and_revert(v) for v in val) + if isinstance(val, datetime) and _key in (SZ_SINCE, SZ_TIME_UNTIL, SZ_UNTIL): + return val.isoformat() + if isinstance(val, StrEnum): + return "".join(word.capitalize() for word in val.value.split("_")) + return val + + def is_valid_zone(zone: evo.Zone) -> bool: """Check if an Evohome zone should have climate and button entities.""" return ( @@ -73,6 +91,8 @@ class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]): for attr in self._evo_state_attr_names: self._device_state_attrs[attr] = getattr(self._evo_device, attr) + self._device_state_attrs = _recurse_and_revert(self._device_state_attrs) + super()._handle_coordinator_update() async def update_attrs(self) -> None: @@ -98,7 +118,7 @@ class EvoChild(EvoEntity): self._evo_id = evo_device.id self._evo_tcs = evo_device.tcs - self._schedule: list[DayOfWeekDhwT] | None = None + self._schedule: list[EvoDayOfWeekDhwT] | None = None self._setpoints: dict[str, Any] = {} @property diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 28950397a048..56e7c7647597 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.2.0"] + "requirements": ["evohome-async==2.0.1"] } diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py index 6e465338ee74..1c4e1344bd18 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -1,19 +1,25 @@ """Service handlers for the Evohome integration.""" from datetime import timedelta +import re from typing import Any, Final from evohomeasync2 import ControlSystem -from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE -from evohomeasync2.schemas.const import ( - S2_DURATION as SZ_DURATION, - S2_PERIOD as SZ_PERIOD, +from evohomeasync2.const import ( + SZ_CAN_BE_TEMPORARY, + SZ_DURATION, + SZ_MODE, + SZ_PERIOD, + SZ_SETPOINT, + SZ_STATE, + SZ_SYSTEM_MODE, + SZ_TIMING_MODE, ) import voluptuous as vol from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, ATTR_STATE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( @@ -25,9 +31,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control from .const import ( - ATTR_DURATION, - ATTR_PERIOD, - ATTR_SETPOINT, DOMAIN, REFRESH_BREAKS_IN_HA_VERSION, RESET_BREAKS_IN_HA_VERSION, @@ -37,15 +40,21 @@ from .const import ( from .coordinator import EvoDataUpdateCoordinator from .helpers import async_create_deprecation_issue_once + +def _as_snake_case(mode: str) -> str: + """Convert a CamelCase string to the snake_case used by the library.""" + return re.sub(r"(? None: def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -> None: """Validate that a set_system_mode service call is properly formed.""" - mode = data[ATTR_MODE] - tcs_modes = {m[SZ_SYSTEM_MODE]: m for m in tcs.allowed_system_modes} + mode = data[SZ_MODE] + tcs_modes = {m[SZ_SYSTEM_MODE].value: m for m in tcs.allowed_system_modes} # Validation occurs here, instead of in the library, because it uses a slightly # different schema (until instead of duration/period) for the method invoked # via this service call - if (mode_info := tcs_modes.get(mode)) is None: + if (mode_info := tcs_modes.get(_as_snake_case(mode))) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_not_supported", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) # voluptuous schema ensures that duration and period are not both present if not mode_info[SZ_CAN_BE_TEMPORARY]: - if ATTR_DURATION in data or ATTR_PERIOD in data: + if SZ_DURATION in data or SZ_PERIOD in data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_cant_be_temporary", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) return timing_mode = mode_info.get(SZ_TIMING_MODE) # will not be None, as can_be_temporary - if timing_mode == SZ_DURATION and ATTR_PERIOD in data: + if timing_mode == SZ_DURATION and SZ_PERIOD in data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_cant_have_period", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) - if timing_mode == SZ_PERIOD and ATTR_DURATION in data: + if timing_mode == SZ_PERIOD and SZ_DURATION in data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_cant_have_duration", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) @@ -243,7 +250,9 @@ def setup_service_functions( payload = { "unique_id": unique_id, "service": call.service, - "data": call.data, + "data": {**call.data, SZ_MODE: _as_snake_case(call.data[SZ_MODE])} + if SZ_MODE in call.data + else call.data, } async_dispatcher_send(hass, DOMAIN, payload) diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 98aaeff25dc3..68565487bcc1 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -5,8 +5,12 @@ import logging from typing import Any, override import evohomeasync2 as evo -from evohomeasync2.const import SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS -from evohomeasync2.schemas.const import DhwState as EvoDhwState, ZoneMode as EvoZoneMode +from evohomeasync2.const import ( + SZ_STATE_STATUS, + SZ_TEMPERATURE_STATUS, + DhwState as EvoDhwState, + ZoneMode as EvoZoneMode, +) from homeassistant.components.water_heater import ( WaterHeaterEntity, diff --git a/homeassistant/components/fan/llm.py b/homeassistant/components/fan/llm.py new file mode 100644 index 000000000000..5296e634f099 --- /dev/null +++ b/homeassistant/components/fan/llm.py @@ -0,0 +1,38 @@ +"""LLM tools for the fan integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from . import DOMAIN +from .intent import INTENT_FAN_SET_SPEED + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_FAN_SET_SPEED,) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/fints/__init__.py b/homeassistant/components/fints/__init__.py index 0113fa752346..030236752ad1 100644 --- a/homeassistant/components/fints/__init__.py +++ b/homeassistant/components/fints/__init__.py @@ -1 +1 @@ -"""The fints component.""" +"""The FinTS integration.""" diff --git a/homeassistant/components/flock/__init__.py b/homeassistant/components/flock/__init__.py index 1b58d21cff88..cb299b9f4217 100644 --- a/homeassistant/components/flock/__init__.py +++ b/homeassistant/components/flock/__init__.py @@ -1 +1 @@ -"""The flock component.""" +"""The Flock integration.""" diff --git a/homeassistant/components/foobot/__init__.py b/homeassistant/components/foobot/__init__.py index 92edde9a5e1c..8afa3abd2348 100644 --- a/homeassistant/components/foobot/__init__.py +++ b/homeassistant/components/foobot/__init__.py @@ -1 +1 @@ -"""The foobot component.""" +"""The Foobot integration.""" diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index e1b9a53372a7..7402bb93252f 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -98,6 +98,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): use_tls=self._use_tls, timeout=60.0, pool_maxsize=30, + redact_debug_log=True, ) except FRITZ_AUTH_EXCEPTIONS: return ERROR_AUTH_INVALID diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index c54624fe151e..fcad98ef9f9e 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -215,6 +215,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): use_tls=self.use_tls, timeout=60.0, pool_maxsize=30, + redact_debug_log=True, ) if not self.connection: @@ -245,7 +246,9 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): { **vars(info), "NewDeviceLog": "***omitted***", + "device_log": "***omitted***", "NewSerialNumber": "***omitted***", + "serial_number": "***omitted***", }, ) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2f9e7e0b1d75..14996cd487e2 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260624.3"] + "requirements": ["home-assistant-frontend==20260624.4"] } diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index 6ab87f3da8ef..500ce5745c24 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -23,6 +23,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN +from .const import GeolocationEntityStateAttribute _LOGGER = logging.getLogger(__name__) @@ -44,7 +45,10 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( def source_match(state: State | None, source: str) -> bool: """Check if the state matches the provided source.""" - return state is not None and state.attributes.get("source") == source + return ( + state is not None + and state.attributes.get(GeolocationEntityStateAttribute.SOURCE) == source + ) async def async_attach_trigger( diff --git a/homeassistant/components/gitlab_ci/__init__.py b/homeassistant/components/gitlab_ci/__init__.py index 93b2a08c714a..27d82026ab9d 100644 --- a/homeassistant/components/gitlab_ci/__init__.py +++ b/homeassistant/components/gitlab_ci/__init__.py @@ -1 +1 @@ -"""The gitlab_ci component.""" +"""The GitLab-CI integration.""" diff --git a/homeassistant/components/haveibeenpwned/__init__.py b/homeassistant/components/haveibeenpwned/__init__.py index adead4ec46e0..69b93fc33ebf 100644 --- a/homeassistant/components/haveibeenpwned/__init__.py +++ b/homeassistant/components/haveibeenpwned/__init__.py @@ -1 +1 @@ -"""The haveibeenpwned component.""" +"""The HaveIBeenPwned integration.""" diff --git a/homeassistant/components/homekit_controller/event.py b/homeassistant/components/homekit_controller/event.py index c477898b79f4..617b6f34b00d 100644 --- a/homeassistant/components/homekit_controller/event.py +++ b/homeassistant/components/homekit_controller/event.py @@ -26,6 +26,12 @@ INPUT_EVENT_VALUES = { InputEventValues.LONG_PRESS: "long_press", } +DOORBELL_EVENT_VALUES = { + InputEventValues.SINGLE_PRESS: "ring", + InputEventValues.DOUBLE_PRESS: "double_press", + InputEventValues.LONG_PRESS: "long_press", +} + class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity): """Representation of a Homekit event entity.""" @@ -50,11 +56,17 @@ class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity): self.entity_description = entity_description + self._event_values = ( + DOORBELL_EVENT_VALUES + if entity_description.device_class == EventDeviceClass.DOORBELL + else INPUT_EVENT_VALUES + ) + # An INPUT_EVENT may support single_press, long_press and # double_press. All are optional. So we have to clamp # InputEventValues for this exact device self._attr_event_types = [ - INPUT_EVENT_VALUES[v] + self._event_values[v] for v in clamp_enum_to_char(InputEventValues, self._char) ] @@ -82,7 +94,7 @@ class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity): # pollable, but always returns None when polled # Make sure we don't explode if we see that edge case. return - self._trigger_event(INPUT_EVENT_VALUES[self._char.value]) + self._trigger_event(self._event_values[self._char.value]) self.async_write_ha_state() diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index e18631c7049b..46934718b7ef 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -48,7 +48,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the HomematicIP Cloud component.""" + """Set up the HomematicIP Cloud integration.""" accesspoints = config.get(DOMAIN, []) for conf in accesspoints: diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index aae5a6227e37..1fe8b126ea62 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the HomematicIP Cloud component.""" +"""Config flow to configure the HomematicIP Cloud integration.""" from collections.abc import Mapping from typing import Any, override @@ -12,7 +12,7 @@ from .hap import HomematicipAuth class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN): - """Config flow for the HomematicIP Cloud component.""" + """Config flow for the HomematicIP Cloud integration.""" VERSION = 2 diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 07e4fbadeb7a..60caa168311e 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -1,4 +1,4 @@ -"""Constants for the HomematicIP Cloud component.""" +"""Constants for the HomematicIP Cloud integration.""" import logging diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 2e4889a53d8c..5e2947b902fe 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -1,4 +1,4 @@ -"""Generic entity for the HomematicIP Cloud component.""" +"""Generic entity for the HomematicIP Cloud integration.""" import contextlib import logging diff --git a/homeassistant/components/homematicip_cloud/errors.py b/homeassistant/components/homematicip_cloud/errors.py index bbee58f7a417..dc753cf62cb4 100644 --- a/homeassistant/components/homematicip_cloud/errors.py +++ b/homeassistant/components/homematicip_cloud/errors.py @@ -1,4 +1,4 @@ -"""Errors for the HomematicIP Cloud component.""" +"""Errors for the HomematicIP Cloud integration.""" from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index df54e669a584..65da63cd344f 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -1,4 +1,4 @@ -"""Access point for the HomematicIP Cloud component.""" +"""Access point for the HomematicIP Cloud integration.""" import asyncio from collections.abc import Callable diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 35c955fa5c95..0cd4816f379d 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -139,7 +139,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): @override def available(self) -> bool: """Return True if shade position data is available.""" - return super().available and self.positions.primary is not None + return super().available and self._is_position_available + + @property + def _is_position_available(self) -> bool: + """Return if the cover contains positional data.""" + return self.positions.primary is not None @property @override @@ -567,9 +572,9 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): @property @override - def available(self) -> bool: - """Return True if shade position data is available.""" - return super().available and self.positions.tilt is not None + def _is_position_available(self) -> bool: + """Return if the cover contains positional data.""" + return self.positions.tilt is not None class PowerViewShadeTopDown(PowerViewShadeBase): diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index d0974f3e3bb2..4c4c4484f9d2 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "platinum", - "requirements": ["aioimmich==0.15.1"] + "requirements": ["aioimmich==0.16.0"] } diff --git a/homeassistant/components/infrared/entity.py b/homeassistant/components/infrared/entity.py index 473c3a0d2a32..5fa7c085c9bd 100644 --- a/homeassistant/components/infrared/entity.py +++ b/homeassistant/components/infrared/entity.py @@ -49,6 +49,14 @@ class InfraredEmitterEntity(RestoreEntity): __last_command_sent: str | None = None + @override + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For infrared emitters this is True if the entity has a device class. + """ + return self.device_class is not None + @property @final @override @@ -101,6 +109,14 @@ class InfraredReceiverEntity(RestoreEntity): __last_signal_received: str | None = None + @override + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For infrared receivers this is True if the entity has a device class. + """ + return self.device_class is not None + @cached_property def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]: """Subscriber callback set, lazily initialized on first access.""" diff --git a/homeassistant/components/infrared/strings.json b/homeassistant/components/infrared/strings.json index 09d705d53cc0..3a21a72eac63 100644 --- a/homeassistant/components/infrared/strings.json +++ b/homeassistant/components/infrared/strings.json @@ -1,6 +1,6 @@ { "entity_component": { - "_": { + "emitter": { "name": "Infrared emitter" }, "receiver": { diff --git a/homeassistant/components/intent_script/llm.py b/homeassistant/components/intent_script/llm.py new file mode 100644 index 000000000000..bc13382248da --- /dev/null +++ b/homeassistant/components/intent_script/llm.py @@ -0,0 +1,51 @@ +"""LLM tools for the intent_script integration.""" + +import slugify as unicode_slug + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from . import ScriptIntentHandler + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return an LLM tool for each configured intent script.""" + if api_id != LLM_API_ASSIST: + return None + + handlers = [ + handler + for handler in intent.async_get(hass) + if isinstance(handler, ScriptIntentHandler) + ] + + exposed_domains = { + state.domain + for state in hass.states.async_all() + if async_should_expose(hass, llm_context.assistant, state.entity_id) + } + handlers = [ + handler + for handler in handlers + if handler.platforms is None or handler.platforms & exposed_domains + ] + + if not handlers: + return None + + # Intent script names come from user configuration, so slugify them into + # valid tool names. + tools: list[Tool] = [ + IntentTool( + unicode_slug.slugify(handler.intent_type, separator="_", lowercase=False), + handler, + ) + for handler in handlers + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py index 628781f6bd88..e6ea6160ffdf 100644 --- a/homeassistant/components/iqvia/config_flow.py +++ b/homeassistant/components/iqvia/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the IQVIA component.""" +"""Config flow to configure the IQVIA integration.""" from typing import Any, override diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index a1ec1e84547f..6ae46c3173be 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -165,10 +165,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version > 2: - # Don't migrate from future version - return False - if entry.version == 1: new_data = {**entry.data} new_options = {**entry.options} diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 718c76aca29e..f652725d5152 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -1,19 +1,24 @@ """Support for KNX button entities.""" -from typing import override +from typing import Any, override -from xknx.devices import RawValue as XknxRawValue +from xknx.devices import ExposeSensor as XknxExposeSensor, RawValue as XknxRawValue from homeassistant import config_entries from homeassistant.components.button import ButtonEntity from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_PAYLOAD, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import ConfigType -from .const import CONF_PAYLOAD_LENGTH, KNX_ADDRESS, KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import CONF_PAYLOAD_LENGTH, CONF_VALUE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_module import KNXModule +from .storage.const import CONF_DATA, CONF_ENTITY, CONF_GA_SEND +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -21,27 +26,60 @@ async def async_setup_entry( config_entry: config_entries.ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the KNX binary sensor platform.""" + """Set up button(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.BUTTON] + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.BUTTON, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiButton, + ), + ) - async_add_entities(KNXButton(knx_module, entity_config) for entity_config in config) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.BUTTON): + entities.extend( + KnxYamlButton(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.BUTTON): + entities.extend( + KnxUiButton(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXButton(KnxYamlEntity, ButtonEntity): +class _KnxButton(ButtonEntity): """Representation of a KNX button.""" + _device: XknxRawValue | XknxExposeSensor + _payload: Any + + @override + async def async_press(self) -> None: + """Press the button.""" + await self._device.set(self._payload) + + +class KnxYamlButton(_KnxButton, KnxYamlEntity): + """Representation of a KNX button configured via YAML.""" + _device: XknxRawValue def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: """Initialize a KNX button.""" + # dpt-value to payload conversion is done in schema validation for yaml config + self._payload = config[CONF_PAYLOAD] self._device = XknxRawValue( xknx=knx_module.xknx, name=config[CONF_NAME], payload_length=config[CONF_PAYLOAD_LENGTH], group_address=config[KNX_ADDRESS], ) - self._payload = config[CONF_PAYLOAD] super().__init__( knx_module=knx_module, unique_id=f"{self._device.remote_value.group_address}_{self._payload}", @@ -49,7 +87,39 @@ class KNXButton(KnxYamlEntity, ButtonEntity): entity_category=config.get(CONF_ENTITY_CATEGORY), ) - @override - async def async_press(self) -> None: - """Press the button.""" - await self._device.set(self._payload) + +class KnxUiButton(_KnxButton, KnxUiEntity): + """Representation of a KNX button configured via the UI.""" + + _device: XknxRawValue | XknxExposeSensor + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize a KNX button.""" + knx_conf = ConfigExtractor(config[DOMAIN]) + button_data = knx_conf.get(CONF_DATA) + if CONF_PAYLOAD in button_data and CONF_PAYLOAD_LENGTH in button_data: + self._payload = int(button_data[CONF_PAYLOAD], 16) + self._device = XknxRawValue( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + payload_length=button_data[CONF_PAYLOAD_LENGTH], + group_address=knx_conf.get_write(CONF_GA_SEND), + ) + else: + dpt_string = knx_conf.get_dpt(CONF_GA_SEND) + self._payload = button_data[CONF_VALUE] + self._device = XknxExposeSensor( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + value_type=dpt_string, + group_address=knx_conf.get_write(CONF_GA_SEND), + respond_to_read=False, + ) + + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index b12d6241bb50..84f73b4255e2 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -26,6 +26,7 @@ KNX_ADDRESS: Final = "address" CONF_INVERT: Final = "invert" CONF_KNX_EXPOSE: Final = "expose" CONF_KNX_INDIVIDUAL_ADDRESS: Final = "individual_address" +CONF_VALUE: Final = "value" ## # Connection constants @@ -178,6 +179,7 @@ SUPPORTED_PLATFORMS_YAML: Final = { SUPPORTED_PLATFORMS_UI: Final = { Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.DATE, diff --git a/homeassistant/components/knx/dpt.py b/homeassistant/components/knx/dpt.py index bb5792c00616..2ab86f0626a3 100644 --- a/homeassistant/components/knx/dpt.py +++ b/homeassistant/components/knx/dpt.py @@ -2,9 +2,9 @@ from collections.abc import Mapping from functools import cache -from typing import Literal, TypedDict +from typing import Literal, NotRequired, TypedDict, cast -from xknx.dpt import DPTBase, DPTComplex, DPTEnum, DPTNumeric +from xknx.dpt import DPTBase, DPTComplex, DPTComplexFieldSchema, DPTEnum, DPTNumeric from xknx.dpt.dpt_16 import DPTString from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass @@ -24,15 +24,28 @@ class DPTInfo(TypedDict): sensor_device_class: SensorDeviceClass | None sensor_state_class: SensorStateClass | None + payload_length: int + + # numeric specific + min: NotRequired[float] + max: NotRequired[float] + step: NotRequired[float] + + # enum specific + options: NotRequired[list[str]] + + # complex specific + schema: NotRequired[list[DPTComplexFieldSchema]] + @cache def get_supported_dpts() -> Mapping[str, DPTInfo]: """Return a mapping of supported DPTs with HA specific attributes.""" - dpts = {} + dpts: dict[str, DPTInfo] = {} for dpt_class in DPTBase.dpt_class_tree(): dpt_number_str = dpt_class.dpt_number_str() ha_dpt_class = _ha_dpt_class(dpt_class) - dpts[dpt_number_str] = DPTInfo( + info = DPTInfo( dpt_class=ha_dpt_class, main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests sub=dpt_class.dpt_sub_number, @@ -40,7 +53,15 @@ def get_supported_dpts() -> Mapping[str, DPTInfo]: unit=_sensor_unit_overrides.get(dpt_number_str, dpt_class.unit), sensor_device_class=_sensor_device_classes.get(dpt_number_str), sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str), + payload_length=dpt_class.payload_length, ) + if ha_dpt_class == "numeric": + _add_numeric_details(info, cast(type[DPTNumeric], dpt_class)) + elif ha_dpt_class == "enum": + _add_enum_details(info, cast(type[DPTEnum], dpt_class)) + elif ha_dpt_class == "complex": + _add_complex_details(info, cast(type[DPTComplex], dpt_class)) + dpts[dpt_number_str] = info return dpts @@ -57,6 +78,23 @@ def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass: raise ValueError("Unsupported DPT class") +def _add_numeric_details(dpt_info: DPTInfo, dpt_cls: type[DPTNumeric]) -> None: + """Add numeric specific details to the DPTInfo.""" + dpt_info["min"] = dpt_cls.value_min + dpt_info["max"] = dpt_cls.value_max + dpt_info["step"] = dpt_cls.resolution + + +def _add_enum_details(dpt_info: DPTInfo, dpt_cls: type[DPTEnum]) -> None: + """Add enum specific details to the DPTInfo.""" + dpt_info["options"] = [o.name.lower() for o in dpt_cls.get_valid_values()] + + +def _add_complex_details(dpt_info: DPTInfo, dpt_cls: type[DPTComplex]) -> None: + """Add complex specific details to the DPTInfo.""" + dpt_info["schema"] = dpt_cls.get_dict_schema() + + _sensor_device_classes: Mapping[str, SensorDeviceClass] = { "7.011": SensorDeviceClass.DISTANCE, "7.012": SensorDeviceClass.CURRENT, diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 0d416c7b7a1b..7c200a77bc37 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -57,6 +57,7 @@ from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, + CONF_VALUE, KNX_ADDRESS, ClimateConf, ColorTempModes, @@ -98,9 +99,12 @@ def _max_payload_value(payload_length: int) -> int: def button_payload_sub_validator(entity_config: OrderedDict) -> OrderedDict: - """Validate a button entity payload configuration.""" + """Validate a button entity payload configuration. + + Returns raw payload and length from value and type (DPT), if given. + """ if _type := entity_config.get(CONF_TYPE): - _payload = entity_config[ButtonSchema.CONF_VALUE] + _payload = entity_config[CONF_VALUE] if (transcoder := DPTBase.parse_transcoder(_type)) is None: raise vol.Invalid(f"'type: {_type}' is not a valid sensor type.") entity_config[CONF_PAYLOAD_LENGTH] = transcoder.payload_length @@ -234,8 +238,6 @@ class ButtonSchema(KNXPlatformSchema): PLATFORM = Platform.BUTTON - CONF_VALUE = "value" - payload_or_value_msg = f"Please use only one of `{CONF_PAYLOAD}` or `{CONF_VALUE}`" length_or_type_msg = ( f"Please use only one of `{CONF_PAYLOAD_LENGTH}` or `{CONF_TYPE}`" diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index d9729684eb2e..530af816b435 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -19,6 +19,9 @@ CONF_GA_TIME: Final = "ga_time" CONF_GA_STEP: Final = "ga_step" +# Button +CONF_GA_SEND: Final = "ga_send" + # Climate CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current" CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 7fffd6baaa0e..26d4c0286908 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -3,7 +3,8 @@ from enum import StrEnum, unique import voluptuous as vol -from xknx.dpt import DPTNumeric +from xknx.dpt import DPTBase, DPTBinary, DPTNumeric +from xknx.exceptions import ConversionError from homeassistant.components.climate import HVACMode from homeassistant.components.number import ( @@ -36,9 +37,11 @@ from ..const import ( CONF_CONTEXT_TIMEOUT, CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, + CONF_PAYLOAD_LENGTH, CONF_RESET_AFTER, CONF_RESPOND_TO_READ, CONF_SYNC_STATE, + CONF_VALUE, DOMAIN, SUPPORTED_PLATFORMS_UI, ClimateConf, @@ -92,6 +95,7 @@ from .const import ( CONF_GA_RED_SWITCH, CONF_GA_SATURATION, CONF_GA_SCENE, + CONF_GA_SEND, CONF_GA_SENSOR, CONF_GA_SETPOINT_SHIFT, CONF_GA_SPEED, @@ -115,6 +119,7 @@ from .knx_selector import ( GASelector, GroupSelect, GroupSelectOption, + KnxPayloadSelector, KNXSectionFlat, SyncStateSelector, ) @@ -169,6 +174,55 @@ BINARY_SENSOR_KNX_SCHEMA = vol.Schema( }, ) + +def _button_data_sub_validator(config: dict) -> dict: + """Validate data matching configured DPT.""" + dpt = config[CONF_GA_SEND].get(CONF_DPT) + transcoder = None + if dpt: + transcoder = DPTBase.parse_transcoder(dpt) + assert transcoder is not None # already checked by GASelector + + if CONF_VALUE in config[CONF_DATA]: + try: + transcoder.to_knx(config[CONF_DATA][CONF_VALUE]) + except ConversionError as ex: + raise vol.Invalid( + f"Value invalid for DPT {transcoder.dpt_number_str()}", + path=([CONF_DATA]), + ) from ex + elif CONF_PAYLOAD_LENGTH in config[CONF_DATA]: + length = config[CONF_DATA][CONF_PAYLOAD_LENGTH] + if length != transcoder.payload_length or ( + length != 0 and transcoder.payload_type is DPTBinary + ): + raise vol.Invalid( + f"Payload length invalid for DPT {transcoder.dpt_number_str()}", + path=([CONF_DATA]), + ) + return config + # without DPT only raw allowed -> payload + payload_length (checked by KnxPayloadSelector) + if CONF_PAYLOAD_LENGTH in config[CONF_DATA]: + return config + raise vol.Invalid("Invalid configuration for button entity") + + +BUTTON_KNX_SCHEMA = AllSerializeFirst( + vol.Schema( + { + vol.Required(CONF_GA_SEND): GASelector( + state=False, + write_required=True, + passive=False, + dpt=["numeric", "enum", "complex", "string"], + dpt_required=False, # for raw payload support + ), + vol.Required(CONF_DATA): KnxPayloadSelector(ga_path=CONF_GA_SEND), + }, + ), + _button_data_sub_validator, +) + COVER_KNX_SCHEMA = AllSerializeFirst( vol.Schema( { @@ -741,6 +795,7 @@ SENSOR_KNX_SCHEMA = AllSerializeFirst( KNX_SCHEMA_FOR_PLATFORM = { Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA, + Platform.BUTTON: BUTTON_KNX_SCHEMA, Platform.CLIMATE: CLIMATE_KNX_SCHEMA, Platform.COVER: COVER_KNX_SCHEMA, Platform.DATE: DATE_KNX_SCHEMA, diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index 9bc0a1cd382c..b216752a58ba 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -6,6 +6,9 @@ from typing import Any, override import voluptuous as vol +from homeassistant.const import CONF_PAYLOAD + +from ..const import CONF_PAYLOAD_LENGTH, CONF_VALUE from ..dpt import HaDptClass, get_supported_dpts from ..validation import ga_validator, maybe_ga_validator, sync_state_validator from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE @@ -159,7 +162,11 @@ class GroupSelect(KNXSelectorBase): class GASelector(KNXSelectorBase): - """Selector for a KNX group address structure.""" + """Selector for a KNX group address structure. + + `dpt_required` optional dpt only apply to dpt-class lists, enums are always required. + `valid_dpt` is used in frontend to filter dropdown menu - no validation is done. + """ selector_type = "knx_group_address" @@ -171,6 +178,7 @@ class GASelector(KNXSelectorBase): write_required: bool = False, state_required: bool = False, dpt: type[Enum] | list[HaDptClass] | None = None, + dpt_required: bool = True, valid_dpt: str | Iterable[str] | None = None, ) -> None: """Initialize the group address selector.""" @@ -180,7 +188,7 @@ class GASelector(KNXSelectorBase): self.write_required = write_required self.state_required = state_required self.dpt = dpt - # valid_dpt is used in frontend to filter dropdown menu - no validation is done + self.dpt_required = dpt_required self.valid_dpt = (valid_dpt,) if isinstance(valid_dpt, str) else valid_dpt self.schema = self.build_schema() @@ -196,6 +204,7 @@ class GASelector(KNXSelectorBase): } if self.dpt is not None: if isinstance(self.dpt, list): + # optional / required is not passed to FE - only validated in BE options["dptClasses"] = self.dpt else: options["dptSelect"] = [ @@ -267,7 +276,8 @@ class GASelector(KNXSelectorBase): """Add DPT validator to the schema.""" if self.dpt is not None: if isinstance(self.dpt, list): - schema[vol.Required(CONF_DPT)] = vol.In(get_supported_dpts()) + marker = vol.Required if self.dpt_required else vol.Optional + schema[marker(CONF_DPT)] = vol.In(get_supported_dpts()) else: schema[vol.Required(CONF_DPT)] = vol.In( {item.value for item in self.dpt} @@ -300,3 +310,64 @@ class SyncStateSelector(KNXSelectorBase): if not self.allow_false and not data: raise vol.Invalid(f"Sync state cannot be {data}") return self.schema(data) + + +class KnxPayloadSelector(KNXSelectorBase): + """Selector for KNX payload configuration. + + Raw payloads are stored as hex strings. + """ + + schema = vol.Any( + { + vol.Required(CONF_VALUE): object, + }, + { + vol.Required(CONF_PAYLOAD): str, + vol.Required(CONF_PAYLOAD_LENGTH): vol.All(int, vol.Range(min=0, max=14)), + }, + ) + selector_type = "knx_payload" + + def __init__(self, ga_path: str) -> None: + """Initialize the KNX payload selector.""" + self.ga_path = ga_path + + @override + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + return { + "type": self.selector_type, + "ga_path": self.ga_path, + } + + @override + def __call__(self, data: Any) -> Any: + """Validate the passed data.""" + validated = self.schema(data) + if CONF_PAYLOAD in validated and CONF_PAYLOAD_LENGTH in validated: + payload = validated[CONF_PAYLOAD] + payload_length = validated[CONF_PAYLOAD_LENGTH] + try: + int_payload = int(payload, 16) + except ValueError as ex: + raise vol.Invalid(f"Invalid payload format: {payload}") from ex + validated[CONF_PAYLOAD] = hex(int_payload) # prepends "0x" if not present + + if int_payload < 0: + raise vol.Invalid(f"Payload cannot be negative: {payload}") + if payload_length == 0: + # DPT 1,2,3 is marked length 0, has 6 bit size + if int_payload > 63: + raise vol.Invalid( + f"Payload exceeds DPT 1,2,3 limit of 0x3f (63): {payload}" + ) + else: + max_payload = (1 << (payload_length * 8)) - 1 + if int_payload > max_payload: + raise vol.Invalid( + f"Payload {payload} exceeds possible maximum for " + f"length {payload_length}: {hex(max_payload)}" + ) + # CONF_VALUE branch needs subvalidator as we don't have the DPT available here + return validated diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index b5af5bee9836..59ff173b8b20 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -453,6 +453,19 @@ } } }, + "button": { + "description": "Entity for sending predefined values.", + "knx": { + "data": { + "description": "The value sent when the button is pressed. The format of the value depends on the DPT of the configured address.", + "label": "Data" + }, + "ga_send": { + "description": "Group address the value is sent to.", + "label": "Address" + } + } + }, "climate": { "description": "The KNX climate platform is used as an interface to heating actuators, HVAC gateways, etc.", "knx": { @@ -1014,6 +1027,19 @@ "project": { "description": "Inspect imported group addresses", "title": "Project" + }, + "selectors": { + "knx-payload-selector": { + "dpt_missing": "No DPT selected – Typed mode not available", + "mode": { + "label": "Payload format", + "raw": "Raw payload", + "typed": "Typed value" + }, + "raw_length": "Payload length", + "raw_length_description": "Length of the raw payload in bytes. For DPT 1, 2 and 3 use `0`.", + "raw_payload": "Raw payload" + } } }, "device_automation": { diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 07ffb24400fa..0abd73db29a1 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.2.5"] + "requirements": ["pylamarzocco==2.4.2"] } diff --git a/homeassistant/components/lawn_mower/llm.py b/homeassistant/components/lawn_mower/llm.py new file mode 100644 index 000000000000..c51d5ecdcbde --- /dev/null +++ b/homeassistant/components/lawn_mower/llm.py @@ -0,0 +1,38 @@ +"""LLM tools for the lawn_mower integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/light/llm.py b/homeassistant/components/light/llm.py new file mode 100644 index 000000000000..5570245444f1 --- /dev/null +++ b/homeassistant/components/light/llm.py @@ -0,0 +1,38 @@ +"""LLM tools for the light integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import INTENT_SET + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_SET,) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/llama_cpp/__init__.py b/homeassistant/components/llama_cpp/__init__.py new file mode 100644 index 000000000000..0153fc32c2d0 --- /dev/null +++ b/homeassistant/components/llama_cpp/__init__.py @@ -0,0 +1,60 @@ +"""The llama.cpp integration.""" + +import logging + +import openai + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) + +from .api import async_create_client, async_list_models + +_LOGGER = logging.getLogger(__name__) +PLATFORMS = (Platform.CONVERSATION,) + +type LlamaCppConfigEntry = ConfigEntry[openai.AsyncOpenAI] + + +async def async_setup_entry(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> bool: + """Set up llama.cpp from a config entry.""" + client = await async_create_client(hass, entry.data) + + # Validate the connection by listing models + try: + await async_list_models(client) + except HomeAssistantError as err: + if err.translation_key == "invalid_auth": + raise ConfigEntryAuthFailed( + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) from err + raise ConfigEntryNotReady( + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) from err + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> bool: + """Unload llama.cpp.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_options(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/llama_cpp/api.py b/homeassistant/components/llama_cpp/api.py new file mode 100644 index 000000000000..bf2c7bfa3959 --- /dev/null +++ b/homeassistant/components/llama_cpp/api.py @@ -0,0 +1,192 @@ +"""API client helper for llama.cpp integration. + +This module contains thin wrappers around the OpenAI completions APIs used +to simplify Home Assistant integration and configuration. It handles client +setup, model validation, and API error handling. +""" + +from collections.abc import Generator, Mapping +from contextlib import contextmanager +import logging +from typing import Any, cast + +import openai +from openai._streaming import AsyncStream +from openai.types.chat import ( + ChatCompletionChunk, + ChatCompletionMessageParam, + ChatCompletionToolParam, +) + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.httpx_client import get_async_client + +from .const import ( + CONF_BASE_URL, + DEFAULT_API_KEY, + DEFAULT_MODEL, + DOMAIN, + RECOMMENDED_CHAT_MODELS, +) + +_LOGGER = logging.getLogger(__name__) + + +# Simple prompt to test model basic chat completion capability. We send tools +# to ensure the model and server correctly supports tool calling. We set a +# minimal max_tokens to consume few resources. +_TEST_MESSAGES: list[ChatCompletionMessageParam] = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is the capital of France?"}, +] +_TEST_TOOLS: list[ChatCompletionToolParam] = [ + { + "type": "function", + "function": { + "name": "test_function", + "description": "Test function.", + "parameters": {"type": "object", "properties": {}}, + }, + } +] +_TEST_MAX_TOKENS = 3 + + +async def async_create_client( + hass: HomeAssistant, config_entry_data: Mapping[str, Any] +) -> openai.AsyncOpenAI: + """Create a new OpenAI client.""" + api_key = config_entry_data.get(CONF_API_KEY) or DEFAULT_API_KEY + client = openai.AsyncOpenAI( + api_key=api_key, + base_url=config_entry_data[CONF_BASE_URL], + http_client=get_async_client(hass), + ) + # Cache current platform data which gets added to each request + # (caching done by library) + _ = await hass.async_add_executor_job(client.platform_headers) + return client + + +async def async_list_models(client: openai.AsyncOpenAI) -> list[str]: + """Return a list of models supported by the client.""" + with api_error_handler(): + page = await client.with_options(timeout=10.0).models.list() + return [model.id async for model in page] + + +async def async_validate_completions( + client: openai.AsyncOpenAI, + model: str, + stream: bool = False, +) -> None: + """Validate that we can speak to the model over the completions API.""" + with api_error_handler(): + result = await client.chat.completions.create( + model=model, + messages=_TEST_MESSAGES, + tools=_TEST_TOOLS, + max_tokens=_TEST_MAX_TOKENS, + stream=stream, + ) + + if stream: + stream_result = cast(AsyncStream[ChatCompletionChunk], result) + async for event in stream_result: + if not event.choices: + continue + if event.choices[0].finish_reason is not None: + continue + + +def recommended_model(models: list[str] | None) -> str: + """Return the selected model from user input.""" + if not models: + return DEFAULT_MODEL + for model in RECOMMENDED_CHAT_MODELS: + if model in models: + return model + return models[0] + + +def model_name_to_title(model_id: str) -> str: + """Convert a model ID into a human-readable title (inverse slugification). + + Examples: + - "deepseek-v4-flash" -> "Deepseek V4 Flash" + - "gpt-4" -> "Gpt 4" + - "llama-3.2-3b-instruct" -> "Llama 3.2 3b Instruct" + - "anthropic/claude-fable-5" -> "Anthropic Claude Fable 5" + """ + words = model_id.replace("-", " ").replace("_", " ").replace("/", " ").split() + return " ".join(word.capitalize() for word in words) + + +def _extract_error_message(err: openai.APIStatusError) -> str: + """Extract a clean error message from an APIStatusError response or message.""" + error_message = "" + if err.response is not None: + try: + json_data = err.response.json() + if isinstance(json_data, dict) and "error" in json_data: + error_message = json_data["error"].get("message") or "" + except ValueError: + pass + return error_message or err.message or str(err) + + +@contextmanager +def api_error_handler() -> Generator[None]: + """Context manager to handle API errors and translate them to HomeAssistantErrors.""" + try: + yield + except openai.APITimeoutError as err: + _LOGGER.error("Timeout talking to API: %s", err) + error_message = err.message or str(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout", + translation_placeholders={"message": error_message}, + ) from err + except openai.APIConnectionError as err: + _LOGGER.error("Connection error talking to API: %s", err) + error_message = err.message or str(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"message": error_message}, + ) from err + except openai.AuthenticationError as err: + _LOGGER.error("Authentication error talking to API: %s", err) + error_message = _extract_error_message(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"message": error_message}, + ) from err + except openai.APIStatusError as err: + _LOGGER.error("Status error talking to API: %s", err) + error_message = _extract_error_message(err) + + if err.status_code == 402: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="quota_exceeded", + translation_placeholders={"message": error_message}, + ) from err + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": error_message}, + ) from err + except openai.OpenAIError as err: + _LOGGER.error("Generic error talking to API: %s", err) + error_message = getattr(err, "message", None) or str(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": error_message}, + ) from err diff --git a/homeassistant/components/llama_cpp/config_flow.py b/homeassistant/components/llama_cpp/config_flow.py new file mode 100644 index 000000000000..8c7e42c29ed5 --- /dev/null +++ b/homeassistant/components/llama_cpp/config_flow.py @@ -0,0 +1,383 @@ +"""Config flow for llama.cpp integration.""" + +import logging +from typing import Any, cast, override + +import openai +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from .api import ( + async_create_client, + async_list_models, + async_validate_completions, + model_name_to_title, + recommended_model, +) +from .const import ( + CONF_BASE_URL, + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_RECOMMENDED, + CONF_STREAMING, + CONF_TEMPERATURE, + CONF_TOP_P, + DEFAULT_BASE_URL, + DOMAIN, + LOGGER, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_BASE_URL, default=DEFAULT_BASE_URL): str, + vol.Optional(CONF_API_KEY): str, + } +) + + +class LlamaCppConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for llama.cpp.""" + + VERSION = 1 + + data: dict[str, Any] | None = None + client: openai.AsyncOpenAI | None = None + models: list[str] | None = None + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + try: + self.client = await async_create_client(self.hass, user_input) + self.models = await async_list_models(self.client) + except HomeAssistantError as err: + LOGGER.error("Connection validation failed: %s", err) + errors["base"] = err.translation_key or "unknown" + except Exception: # pylint: disable=broad-except # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.data = user_input + return await self.async_step_model() + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_model( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selecting a model.""" + assert self.client is not None + assert self.models is not None + assert self.data is not None + errors = {} + if user_input is not None: + model = user_input[CONF_CHAT_MODEL] + try: + await async_validate_completions( + self.client, + model=model, + stream=False, + ) + except HomeAssistantError as err: + LOGGER.error("Model completion validation failed: %s", err) + errors["base"] = err.translation_key or "unknown" + else: + stream_support = True + try: + await async_validate_completions( + self.client, + model=model, + stream=True, + ) + except HomeAssistantError: + stream_support = False + + base_options = { + **user_input, + } + return self.async_create_entry( + title=self.data[CONF_BASE_URL], + data={ + **self.data, + CONF_STREAMING: stream_support, + }, + subentries=[ + { + "subentry_type": "conversation", + "data": { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + **base_options, + }, + "title": model_name_to_title(model), + "unique_id": None, + }, + ], + ) + + return self.async_show_form( + step_id="model", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional( + CONF_CHAT_MODEL, + ): SelectSelector( + SelectSelectorConfig( + options=self.models, + translation_key=CONF_CHAT_MODEL, + mode=SelectSelectorMode.DROPDOWN, + custom_value=True, + ), + ), + } + ), + { + CONF_CHAT_MODEL: (user_input or {}).get( + CONF_CHAT_MODEL, recommended_model(self.models) + ), + }, + ), + errors=errors, + ) + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return { + "conversation": ConversationSubentryFlowHandler, + } + + +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" + + last_rendered_recommended = False + options: dict[str, Any] | None = None + models: list[str] | None = None + + @property + def _openai_client(self) -> openai.AsyncOpenAI: + """Return the OpenAI client.""" + return cast(openai.AsyncOpenAI, self._get_entry().runtime_data) + + async def _get_models(self) -> list[str] | None: + """Return the list of models.""" + if self.models is None: + self.models = await async_list_models(self._openai_client) + return self.models + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a subentry.""" + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + try: + models = await self._get_models() + except HomeAssistantError: + return self.async_abort(reason="cannot_connect") + self.options = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_CHAT_MODEL: recommended_model(models), + } + self.last_rendered_recommended = cast( + bool, self.options.get(CONF_RECOMMENDED, False) + ) + return await self.async_step_init() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of a subentry.""" + return await self.async_step_init() + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manage initial options.""" + # abort if entry is not loaded + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if self.options is None: + self.options = self._get_reconfigure_subentry().data.copy() + self.last_rendered_recommended = cast( + bool, self.options.get(CONF_RECOMMENDED, False) + ) + + try: + models = await self._get_models() + except HomeAssistantError: + return self.async_abort(reason="cannot_connect") + + options = self.options + + if user_input is not None: + model = user_input[CONF_CHAT_MODEL] + try: + await async_validate_completions( + self._openai_client, + model=model, + stream=self._get_entry().data.get(CONF_STREAMING, False), + ) + except HomeAssistantError as err: + LOGGER.error("Model completion validation failed: %s", err) + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + llama_cpp_config_option_schema(self.hass, options, models) + ), + user_input, + ), + errors={"base": err.translation_key or "unknown"}, + ) + + if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: + if self.source == "user": + return self.async_create_entry( + title=model_name_to_title(user_input[CONF_CHAT_MODEL]), + data=user_input, + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, + title=model_name_to_title(user_input[CONF_CHAT_MODEL]), + ) + + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL], + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API, []), + } + + schema = llama_cpp_config_option_schema(self.hass, options, models) + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema), options + ), + ) + + +def llama_cpp_config_option_schema( + hass: HomeAssistant, + options: dict[str, Any], + models: list[str] | None = None, +) -> dict: + """Return a schema for llama.cpp completion options.""" + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ] + LOGGER.debug("Available LLM APIs: %s", hass_apis) + + schema: dict[vol.Required | vol.Optional, Any] = {} + + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + } + ) + schema.update( + { + vol.Optional( + CONF_CHAT_MODEL, + description={"suggested_value": options.get(CONF_CHAT_MODEL)}, + default=options.get(CONF_CHAT_MODEL, recommended_model(models)), + ): SelectSelector( + SelectSelectorConfig( + options=models or [], + translation_key=CONF_CHAT_MODEL, + mode=SelectSelectorMode.DROPDOWN, + custom_value=True, + ), + ), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } + ) + + if options.get(CONF_RECOMMENDED): + return schema + + schema.update( + { + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options.get(CONF_MAX_TOKENS)}, + default=RECOMMENDED_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options.get(CONF_TOP_P)}, + default=RECOMMENDED_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options.get(CONF_TEMPERATURE)}, + default=RECOMMENDED_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + } + ) + return schema diff --git a/homeassistant/components/llama_cpp/const.py b/homeassistant/components/llama_cpp/const.py new file mode 100644 index 000000000000..401d2a99abc3 --- /dev/null +++ b/homeassistant/components/llama_cpp/const.py @@ -0,0 +1,30 @@ +"""Constants for the llama.cpp integration.""" + +import logging + +DOMAIN = "llama_cpp" +LOGGER = logging.getLogger(__package__) + +DEFAULT_CONVERSATION_NAME = "llama.cpp Conversation" + +CONF_CHAT_MODEL = "chat_model" +CONF_MAX_TOKENS = "max_tokens" +CONF_TEMPERATURE = "temperature" +CONF_TOP_P = "top_p" +CONF_BASE_URL = "base_url" +CONF_RECOMMENDED = "recommended" +CONF_STREAMING = "streaming" + +# Some servers set placeholder model names which we can use as a default +DEFAULT_MODEL = "gpt-3.5-turbo" +RECOMMENDED_CHAT_MODELS = [ + DEFAULT_MODEL, + "gpt-4", + "local-model", +] +RECOMMENDED_MAX_TOKENS = 3000 +RECOMMENDED_TEMPERATURE = 0.7 +RECOMMENDED_TOP_P = 1.0 + +DEFAULT_BASE_URL = "http://localhost:8080/v1" +DEFAULT_API_KEY = "sk-0000000000000000000" diff --git a/homeassistant/components/llama_cpp/conversation.py b/homeassistant/components/llama_cpp/conversation.py new file mode 100644 index 000000000000..44ff4c07d7fe --- /dev/null +++ b/homeassistant/components/llama_cpp/conversation.py @@ -0,0 +1,83 @@ +"""Conversation support for llama.cpp.""" + +from typing import Literal, override + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LlamaCppConfigEntry +from .const import DOMAIN +from .entity import LlamaCppBaseLLMEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LlamaCppConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry in config_entry.subentries.values(): + async_add_entities( + [LlamaCppConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class LlamaCppConversationEntity( + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + LlamaCppBaseLLMEntity, +): + """llama.cpp conversation agent.""" + + def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the agent.""" + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + @override + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + @override + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + conversation.async_set_agent(self.hass, self.entry, self) + + @override + async def async_will_remove_from_hass(self) -> None: + """When entity will be removed from Home Assistant.""" + conversation.async_unset_agent(self.hass, self.entry) + await super().async_will_remove_from_hass() + + @override + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process a sentence.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + await self._async_handle_chat_log(chat_log) + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/llama_cpp/entity.py b/homeassistant/components/llama_cpp/entity.py new file mode 100644 index 000000000000..605c9ffb9793 --- /dev/null +++ b/homeassistant/components/llama_cpp/entity.py @@ -0,0 +1,457 @@ +"""Base entity for llama.cpp Conversation.""" + +import base64 +from collections.abc import AsyncGenerator, Callable +import json +import logging +import mimetypes +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, cast + +from openai import AsyncOpenAI +from openai._streaming import AsyncStream +from openai._types import Omit +from openai.types.chat import ( + ChatCompletion, + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionContentPartParam, + ChatCompletionContentPartTextParam, + ChatCompletionFunctionToolParam, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCall, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema +import voluptuous as vol +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from .api import api_error_handler +from .const import ( + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_STREAMING, + CONF_TEMPERATURE, + CONF_TOP_P, + DEFAULT_MODEL, + DOMAIN, + LOGGER, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, +) + +if TYPE_CHECKING: + from . import LlamaCppConfigEntry + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +_LOGGER = logging.getLogger(__name__) + + +def _format_structured_output( + name: str, structure: vol.Schema, llm_api: llm.APIInstance | None +) -> ResponseFormatJSONSchema: + """Format structured output specification.""" + schema = convert( + structure, custom_serializer=llm_api.custom_serializer if llm_api else None + ) + return ResponseFormatJSONSchema( + type="json_schema", + json_schema={ + "name": name, + "strict": True, + "schema": cast(dict[str, object], schema), + }, + ) + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionFunctionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition( + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + _LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to OpenAI API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="json_parse_error", + translation_placeholders={"message": str(err)}, + ) from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the OpenAI API message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + if isinstance(tool_call, ChatCompletionMessageFunctionToolCall) + ] + yield data + + +def _convert_content_to_param( + content: conversation.Content, +) -> ChatCompletionMessageParam: + """Convert any native chat message for this agent to the native format.""" + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + if not isinstance(content, conversation.AssistantContent) or not content.tool_calls: + if isinstance(content, conversation.SystemContent): + return ChatCompletionSystemMessageParam( + role="system", + content=content.content or "", + ) + return cast( + ChatCompletionMessageParam, + {"role": content.role, "content": content.content or ""}, + ) + + return ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + tool_calls=[ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + type="function", + ) + for tool_call in content.tool_calls + ], + ) + + +async def _transform_stream( + result: AsyncStream[ChatCompletionChunk], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform an OpenAI delta stream into HA format.""" + current_tool_call: dict[str, Any] | None = None + yielded_role = False + + async for chunk in result: + LOGGER.debug("Received chunk: %s", chunk) + if not chunk.choices: + continue + choice = chunk.choices[0] + + if choice.finish_reason: + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["tool_name"], + tool_args=_decode_tool_arguments( + current_tool_call["tool_args"] + ) + if current_tool_call["tool_args"] + else {}, + ) + ] + } + break + + delta = choice.delta + + if current_tool_call is None and not delta.tool_calls: + yield_dict: conversation.AssistantContentDeltaDict = {} + if not yielded_role and delta.role == "assistant": + yield_dict["role"] = "assistant" + yielded_role = True + if delta.content is not None: + yield_dict["content"] = delta.content + if yield_dict: + yield yield_dict + continue + + if ( + not delta.tool_calls + or not (delta_tool_call := delta.tool_calls[0]) + or not delta_tool_call.function + ): + continue + + if current_tool_call and delta_tool_call.index == current_tool_call["index"]: + current_tool_call["tool_args"] += delta_tool_call.function.arguments or "" + continue + + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["tool_name"], + tool_args=_decode_tool_arguments( + current_tool_call["tool_args"] + ), + ) + ] + } + + current_tool_call = { + "index": delta_tool_call.index, + "id": delta_tool_call.id, + "tool_name": delta_tool_call.function.name, + "tool_args": delta_tool_call.function.arguments or "", + } + + +class LlamaCppBaseLLMEntity(Entity): + """llama.cpp base LLM entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: LlamaCppConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="llama.cpp", + model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT_MODEL), + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, + ) -> None: + """Generate an answer for the chat log.""" + options = self.subentry.data + + tools: list[ChatCompletionFunctionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + model: str = options.get(CONF_CHAT_MODEL, DEFAULT_MODEL) + messages = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + response_format: ResponseFormatJSONSchema | Omit = Omit() + if structure and structure_name: + response_format = _format_structured_output( + structure_name, structure, chat_log.llm_api + ) + + last_content = chat_log.content[-1] + if ( + isinstance(last_content, conversation.UserContent) + and last_content.attachments + ): + files = await async_prepare_files_for_prompt( + self.hass, + [a.path for a in last_content.attachments], + ) + for i in range(len(messages) - 1, -1, -1): + if messages[i]["role"] == "user": + user_msg = cast(ChatCompletionUserMessageParam, messages[i]) + current_content = user_msg.get("content") + if isinstance(current_content, str): + user_msg["content"] = [ + ChatCompletionContentPartTextParam( + type="text", text=current_content + ), + *files, + ] + break + + client: AsyncOpenAI = self.entry.runtime_data + streaming = bool( + self.entry.data.get(CONF_STREAMING, options.get(CONF_STREAMING, False)) + ) + + for _iteration in range(MAX_TOOL_ITERATIONS): + with api_error_handler(): + result = await client.chat.completions.create( + messages=messages, + model=model, + tools=tools or Omit(), + response_format=response_format, + max_tokens=cast( + int, options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS) + ), + top_p=cast(float, options.get(CONF_TOP_P, RECOMMENDED_TOP_P)), + temperature=cast( + float, options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE) + ), + user=chat_log.conversation_id, + stream=cast(Any, streaming), + ) + + convert_message: Callable[[Any], Any] + async_generator: AsyncGenerator[conversation.AssistantContentDeltaDict] + if streaming: + convert_message = _convert_content_to_param + async_generator = _transform_stream( + cast(AsyncStream[ChatCompletionChunk], result) + ) + else: + convert_message = _convert_content_to_chat_message + async_generator = _transform_response( + cast(ChatCompletion, result).choices[0].message + ) + + messages.extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, async_generator + ) + if (msg := convert_message(content)) + ] + ) + + if not chat_log.unresponded_tool_results: + break + + +async def async_prepare_files_for_prompt( + hass: HomeAssistant, files: list[Path] +) -> list[ChatCompletionContentPartParam]: + """Prepare files for OpenAI-compatible API. + + Caller needs to ensure that the files are allowed. + """ + + def guess_file_type(file_path: Path) -> tuple[str | None, str | None]: + """Guess the file type based on the file extension.""" + return mimetypes.guess_type(str(file_path)) + + def append_files_to_content() -> list[ChatCompletionContentPartParam]: + content: list[ChatCompletionContentPartParam] = [] + + for file_path in files: + if not file_path.exists(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_not_found", + translation_placeholders={"file_path": str(file_path)}, + ) + + mime_type, _ = guess_file_type(file_path) + + if not mime_type or not mime_type.startswith(("image/", "application/pdf")): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_file_type", + translation_placeholders={"file_path": str(file_path)}, + ) + + base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8") + + if mime_type.startswith("image/"): + content.append( + { + "type": "image_url", + "image_url": { + "url": f"data:{mime_type};base64,{base64_file}", + "detail": "auto", + }, + } + ) + elif mime_type.startswith("application/pdf"): + content.append( + { + "type": "text", + "text": f"[File: {file_path.name}]\nContent: {base64_file}", + } + ) + + return content + + return await hass.async_add_executor_job(append_files_to_content) diff --git a/homeassistant/components/llama_cpp/manifest.json b/homeassistant/components/llama_cpp/manifest.json new file mode 100644 index 000000000000..1285be7afabf --- /dev/null +++ b/homeassistant/components/llama_cpp/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "llama_cpp", + "name": "llama.cpp", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/llama_cpp", + "integration_type": "service", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["openai==2.21.0"] +} diff --git a/homeassistant/components/llama_cpp/quality_scale.yaml b/homeassistant/components/llama_cpp/quality_scale.yaml new file mode 100644 index 000000000000..672cef1fdef1 --- /dev/null +++ b/homeassistant/components/llama_cpp/quality_scale.yaml @@ -0,0 +1,104 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions are registered by this integration. + appropriate-polling: + status: exempt + comment: The integration does not poll and is push-based. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions are registered by this integration. + docs-conditions: + status: exempt + comment: No custom conditions are supported by this integration. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: No custom triggers are supported by this integration. + entity-event-setup: + status: exempt + comment: No event entities or helper events are supported by this integration. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No service actions are registered by this integration. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Conversation entities do not have an unavailable state. + integration-owner: done + log-when-unavailable: + status: exempt + comment: Conversation entities do not have an unavailable state. + parallel-updates: + status: exempt + comment: No periodic updates are performed by this integration. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: The integration does not support discovery. + discovery: + status: exempt + comment: The integration does not support discovery. + docs-data-update: + status: exempt + comment: No periodic data updates are performed by this integration. + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: The integration does not support physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: No physical devices are supported. + entity-category: + status: exempt + comment: Conversation entity does not require an entity category. + entity-device-class: + status: exempt + comment: Conversation entity does not require a device class. + entity-disabled-by-default: + status: exempt + comment: Conversation entity should be enabled by default. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: No icons are defined for this integration. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair issues are defined for this integration. + stale-devices: + status: exempt + comment: No physical devices are supported. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/llama_cpp/strings.json b/homeassistant/components/llama_cpp/strings.json new file mode 100644 index 000000000000..d298773a914e --- /dev/null +++ b/homeassistant/components/llama_cpp/strings.json @@ -0,0 +1,98 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "api_error": "[%key:common::config_flow::error::unknown%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "quota_exceeded": "Your account or API key has insufficient credits.", + "timeout": "Connection timed out.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "model": { + "data": { + "chat_model": "[%key:common::generic::model%]" + }, + "data_description": { + "chat_model": "Select the model to use." + } + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "base_url": "URL" + }, + "data_description": { + "api_key": "API key for the server (optional).", + "base_url": "Base URL of your running OpenAI-compatible server (e.g. http://localhost:8080/v1)." + } + } + } + }, + "config_subentries": { + "conversation": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "entry_type": "Conversation agent", + "initiate_flow": { + "user": "Add conversation agent" + }, + "step": { + "init": { + "data": { + "chat_model": "[%key:common::generic::model%]", + "llm_hass_api": "Control Home Assistant", + "max_tokens": "Maximum tokens to return in response", + "name": "[%key:common::config_flow::data::name%]", + "prompt": "Instructions", + "recommended": "Recommended model settings", + "temperature": "Temperature", + "top_p": "Top P" + }, + "data_description": { + "chat_model": "Select the model to use.", + "llm_hass_api": "Select the level of control over Home Assistant.", + "max_tokens": "Select the maximum number of tokens to return.", + "prompt": "Instruct how the LLM should respond. This can be a template.", + "recommended": "Select whether to use recommended model settings.", + "temperature": "Select the temperature for response variability.", + "top_p": "Select the top P value for response diversity." + } + } + } + } + }, + "exceptions": { + "api_error": { + "message": "API error: {message}." + }, + "cannot_connect": { + "message": "Cannot connect to the server: {message}." + }, + "file_not_found": { + "message": "File does not exist: {file_path}." + }, + "invalid_auth": { + "message": "Invalid authentication: {message}." + }, + "json_parse_error": { + "message": "Unexpected tool argument response: {message}." + }, + "quota_exceeded": { + "message": "Your account or API key has insufficient credits: {message}." + }, + "timeout": { + "message": "Connection timed out: {message}." + }, + "unsupported_file_type": { + "message": "Only images and PDF are supported by the OpenAI API, {file_path} is not an image file or PDF." + } + } +} diff --git a/homeassistant/components/llm/__init__.py b/homeassistant/components/llm/__init__.py new file mode 100644 index 000000000000..3f8d87eb2a05 --- /dev/null +++ b/homeassistant/components/llm/__init__.py @@ -0,0 +1,88 @@ +"""The LLM integration. + +Owns the LLM tools platform: integrations contribute tools to the LLM APIs +through an ``/llm.py`` platform with an ``async_get_tools`` hook. +The platforms are loaded lazily and queried per request. The framework +(``Tool``, the APIs) lives in ``homeassistant.helpers.llm``. +""" + +from dataclasses import dataclass +import logging +from typing import Protocol + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms +from homeassistant.helpers.llm import LLMContext, Tool +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +DATA_PLATFORMS: HassKey[LazyIntegrationPlatforms[LLMToolsPlatformProtocol]] = HassKey( + "llm_platforms" +) + + +@dataclass(slots=True) +class LLMTools: + """Tools and an optional prompt fragment contributed by a platform.""" + + tools: list[Tool] + prompt: str | None = None + + +class LLMToolsPlatformProtocol(Protocol): + """Define the format that LLM tools platforms can have.""" + + @callback + def async_get_tools( + self, hass: HomeAssistant, llm_context: LLMContext, api_id: str + ) -> LLMTools | None: + """Return the integration's LLM tools for the given context and API. + + Return None when the integration has nothing for the given API. + """ + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the LLM integration.""" + hass.data[DATA_PLATFORMS] = LazyIntegrationPlatforms( + hass, DOMAIN, _process_llm_tools_platform + ) + return True + + +@callback +def _process_llm_tools_platform( + hass: HomeAssistant, domain: str, platform: LLMToolsPlatformProtocol +) -> LLMToolsPlatformProtocol: + """Process an integration's LLM tools platform.""" + return platform + + +async def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools: + """Return the tools and merged prompt from all integration platforms.""" + platforms = await hass.data[DATA_PLATFORMS].async_get_platforms() + + tools: list[Tool] = [] + prompts: list[str] = [] + # Sort by domain so the tool and prompt order is independent of load order. + for domain, platform in sorted(platforms.items()): + try: + result = platform.async_get_tools(hass, llm_context, api_id) + except Exception: + _LOGGER.exception("Error getting tools from LLM platform %s", domain) + continue + if result is None: + continue + tools.extend(result.tools) + if result.prompt: + prompts.append(result.prompt) + return LLMTools(tools=tools, prompt="\n".join(prompts) if prompts else None) diff --git a/homeassistant/components/llm/const.py b/homeassistant/components/llm/const.py new file mode 100644 index 000000000000..4a9d96c3ec5c --- /dev/null +++ b/homeassistant/components/llm/const.py @@ -0,0 +1,3 @@ +"""Constants for the LLM integration.""" + +DOMAIN = "llm" diff --git a/homeassistant/components/llm/llm.py b/homeassistant/components/llm/llm.py new file mode 100644 index 000000000000..c63f4098c245 --- /dev/null +++ b/homeassistant/components/llm/llm.py @@ -0,0 +1,45 @@ +"""LLM tools provided by the llm integration.""" + +from typing import override + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.llm import LLMContext, Tool, ToolInput +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonObjectType + +from . import LLMTools + + +class GetDateTimeTool(Tool): + """Tool for getting the current date and time.""" + + name = "GetDateTime" + description = "Provides the current date and time." + + @override + async def async_call( + self, + hass: HomeAssistant, + tool_input: ToolInput, + llm_context: LLMContext, + ) -> JsonObjectType: + """Get the current date and time.""" + now = dt_util.now() + + return { + "success": True, + "result": { + "date": now.strftime("%Y-%m-%d"), + "time": now.strftime("%H:%M:%S"), + "timezone": now.strftime("%Z"), + "weekday": now.strftime("%A"), + }, + } + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools: + """Return the always-available LLM tools.""" + return LLMTools(tools=[GetDateTimeTool()]) diff --git a/homeassistant/components/llm/manifest.json b/homeassistant/components/llm/manifest.json new file mode 100644 index 000000000000..c58559a46022 --- /dev/null +++ b/homeassistant/components/llm/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "llm", + "name": "LLM", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/llm", + "integration_type": "system", + "iot_class": "calculated", + "quality_scale": "internal" +} diff --git a/homeassistant/components/mcp/auth.py b/homeassistant/components/mcp/auth.py new file mode 100644 index 000000000000..6b0ee76e1990 --- /dev/null +++ b/homeassistant/components/mcp/auth.py @@ -0,0 +1,36 @@ +"""Authentication helper classes for the Model Context Protocol integration.""" + +from dataclasses import dataclass +import re + +import httpx +from yarl import URL + +# Headers and regex for WWW-Authenticate parsing for rfc9728 +WWW_AUTHENTICATE_HEADER = "WWW-Authenticate" +RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"' +SCOPES_REGEXP = r'scope="([^"]+)"' + + +@dataclass +class AuthenticateHeader: + """Class to hold info from the WWW-Authenticate header for supporting rfc9728.""" + + resource_metadata_url: str + scopes: list[str] | None = None + + @classmethod + def from_header( + cls, url: str, error_response: httpx.Response + ) -> AuthenticateHeader | None: + """Create AuthenticateHeader from WWW-Authenticate header.""" + if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not ( + match := re.search(RESOURCE_METADATA_REGEXP, header) + ): + return None + resource_metadata_url = str(URL(url).join(URL(match.group(1)))) + scope_match = re.search(SCOPES_REGEXP, header) + return cls( + resource_metadata_url=resource_metadata_url, + scopes=scope_match.group(1).split(" ") if scope_match else None, + ) diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 1e77143359dc..982cc0a4884f 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Iterable, Mapping from dataclasses import dataclass import logging -import re from typing import Any, cast, override import httpx @@ -24,6 +23,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from . import async_get_config_entry_implementation from .application_credentials import authorization_server_context +from .auth import AuthenticateHeader from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN from .coordinator import TokenManager, mcp_client @@ -35,35 +35,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) -# Headers and regex for WWW-Authenticate parsing for rfc9728 -WWW_AUTHENTICATE_HEADER = "WWW-Authenticate" -RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"' OAUTH_PROTECTED_RESOURCE_ENDPOINT = "/.well-known/oauth-protected-resource" -SCOPES_REGEXP = r'scope="([^"]+)"' - - -@dataclass -class AuthenticateHeader: - """Class to hold info from the WWW-Authenticate header for supporting rfc9728.""" - - resource_metadata_url: str - scopes: list[str] | None = None - - @classmethod - def from_header( - cls, url: str, error_response: httpx.Response - ) -> AuthenticateHeader | None: - """Create AuthenticateHeader from WWW-Authenticate header.""" - if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not ( - match := re.search(RESOURCE_METADATA_REGEXP, header) - ): - return None - resource_metadata_url = str(URL(url).join(URL(match.group(1)))) - scope_match = re.search(SCOPES_REGEXP, header) - return cls( - resource_metadata_url=resource_metadata_url, - scopes=scope_match.group(1).split(" ") if scope_match else None, - ) @dataclass @@ -369,6 +341,8 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Perform reauth upon an API authentication error.""" + if entry_data and "auth_header" in entry_data: + self.auth_header = entry_data["auth_header"] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -379,6 +353,13 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): return self.async_show_form(step_id="reauth_confirm") config_entry = self._get_reauth_entry() self.data = {**config_entry.data} + if "auth_implementation" not in self.data: + # For entries configured without authentication (no-auth), any authentication + # failure (from a tool call or coordinator update) requires upgrading to OAuth. + # We bypass validate_input connection handshake (which might succeed if the server + # doesn't restrict the connection handshake itself) and proceed directly to OAuth discovery. + return await self.async_step_auth_discovery() + self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment] self.hass, config_entry ) diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index e9bffdc0c8f5..4257449c94da 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -18,12 +18,17 @@ from voluptuous_openapi import convert_to_voluptuous from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import llm from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.json import JsonObjectType +from .auth import AuthenticateHeader from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -98,6 +103,7 @@ class ModelContextProtocolTool(llm.Tool): description: str | None, parameters: vol.Schema, server_url: str, + config_entry: ConfigEntry, token_manager: TokenManager | None = None, ) -> None: """Initialize the tool.""" @@ -105,6 +111,7 @@ class ModelContextProtocolTool(llm.Tool): self.description = description self.parameters = parameters self.server_url = server_url + self.config_entry = config_entry self.token_manager = token_manager @override @@ -126,9 +133,32 @@ class ModelContextProtocolTool(llm.Tool): except TimeoutError as error: _LOGGER.debug("Timeout when calling tool: %s", error) raise HomeAssistantError(f"Timeout when calling tool: {error}") from error + except OAuth2TokenRequestReauthError as error: + _LOGGER.debug("OAuth token request failed when calling tool: %s", error) + self.config_entry.async_start_reauth(hass) + raise ConfigEntryAuthFailed( + "OAuth token request failed when calling tool" + ) from error except httpx.HTTPStatusError as error: _LOGGER.debug("Error when calling tool: %s", error) + if error.response.status_code == 401: + auth_header = AuthenticateHeader.from_header( + self.server_url, error.response + ) + self.config_entry.async_start_reauth( + hass, data={"auth_header": auth_header} + ) + raise ConfigEntryAuthFailed( + "The MCP server requires authentication" + ) from error raise HomeAssistantError(f"Error when calling tool: {error}") from error + except httpx.HTTPError as error: + _LOGGER.debug( + "Error communicating with MCP server when calling tool: %s", error + ) + raise HomeAssistantError( + f"Error communicating with MCP server when calling tool: {error}" + ) from error return result.model_dump(exclude_unset=True, exclude_none=True) @@ -169,9 +199,18 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): except TimeoutError as error: _LOGGER.debug("Timeout when listing tools: %s", error) raise UpdateFailed(f"Timeout when listing tools: {error}") from error + except OAuth2TokenRequestReauthError as error: + _LOGGER.debug("OAuth token request failed: %s", error) + raise ConfigEntryAuthFailed("OAuth token request failed") from error except httpx.HTTPStatusError as error: _LOGGER.debug("Error communicating with API: %s", error) - if error.response.status_code == 401 and self.token_manager is not None: + if error.response.status_code == 401: + auth_header = AuthenticateHeader.from_header( + self.config_entry.data[CONF_URL], error.response + ) + self.config_entry.async_start_reauth( + self.hass, data={"auth_header": auth_header} + ) raise ConfigEntryAuthFailed( "The MCP server requires authentication" ) from error @@ -195,6 +234,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): tool.description, parameters, self.config_entry.data[CONF_URL], + self.config_entry, self.token_manager, ) ) diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 3af6fb4806a3..4317bb3d5494 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -3,12 +3,16 @@ This registers HTTP endpoints that support the Streamable HTTP protocol as well as the older SSE as a transport layer. -The Streamable HTTP protocol uses a single HTTP endpoint: +The Streamable HTTP protocol uses these HTTP endpoints: -- /api/mcp_server: The Streamable HTTP endpoint currently implements the +- /api/mcp: The Streamable HTTP endpoint currently implements the stateless protocol for simplicity. This receives client requests and sends them to the MCP server, then waits for a response to send back to - the client. + the client. This serves the configured LLM APIs and does not require + admin access. +- /api/mcp/: The same Streamable HTTP endpoint, but exposing a + specific LLM API selected by its ID. These endpoints require admin access, + except for the Assist API. The older SSE protocol has two HTTP endpoints: @@ -43,6 +47,7 @@ from homeassistant.components import conversation from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import Unauthorized from homeassistant.helpers import llm from .const import DOMAIN @@ -67,6 +72,7 @@ def async_register(hass: HomeAssistant) -> None: hass.http.register_view(ModelContextProtocolSSEView()) hass.http.register_view(ModelContextProtocolMessagesView()) hass.http.register_view(ModelContextProtocolStreamableView()) + hass.http.register_view(ModelContextProtocolStreamableApiView()) def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry: @@ -127,7 +133,7 @@ async def create_streams() -> AsyncGenerator[Streams]: async def create_mcp_server( - hass: HomeAssistant, context: Context, entry: MCPServerConfigEntry + hass: HomeAssistant, context: Context, llm_api_id: str | list[str] ) -> tuple[Server, InitializationOptions]: """Initialize the MCP server to ensure it's ready to handle requests.""" llm_context = llm.LLMContext( @@ -137,7 +143,6 @@ async def create_mcp_server( assistant=conversation.DOMAIN, device_id=None, ) - llm_api_id = entry.data[CONF_LLM_HASS_API] server = await create_server(hass, llm_api_id, llm_context) options = await hass.async_add_executor_job( server.create_initialization_options # Reads package for version info @@ -165,7 +170,9 @@ class ModelContextProtocolSSEView(HomeAssistantView): entry = async_get_config_entry(hass) session_manager = entry.runtime_data - server, options = await create_mcp_server(hass, self.context(request), entry) + server, options = await create_mcp_server( + hass, self.context(request), entry.data[CONF_LLM_HASS_API] + ) async with ( create_streams() as streams, @@ -231,65 +238,92 @@ class ModelContextProtocolMessagesView(HomeAssistantView): return web.Response(status=200) +async def _async_handle_streamable_message( + request: web.Request, context: Context, llm_api_id: str | list[str] +) -> web.StreamResponse: + """Process a single JSON-RPC message for the given LLM API.""" + hass = request.app[KEY_HASS] + + # The request must include a JSON-RPC message + if CONTENT_TYPE_JSON not in request.headers.get("accept", ""): + raise HTTPBadRequest(text=f"Client must accept {CONTENT_TYPE_JSON}") + if request.content_type != CONTENT_TYPE_JSON: + raise HTTPBadRequest(text=f"Content-Type must be {CONTENT_TYPE_JSON}") + try: + json_data = await request.json() + message = types.JSONRPCMessage.model_validate(json_data) + except ValueError as err: + _LOGGER.debug("Failed to parse message as JSON-RPC message: %s", err) + raise HTTPBadRequest(text="Request must be a JSON-RPC message") from err + + _LOGGER.debug("Received client message: %s", message) + + # For notifications and responses only, return 202 Accepted + if not isinstance(message.root, JSONRPCRequest): + _LOGGER.debug("Notification or response received, returning 202") + return web.Response(status=HTTPStatus.ACCEPTED) + + # The MCP server runs as a background task for the duration of the + # request. We open a buffered stream pair to communicate with it. The + # request is sent to the MCP server and we wait for a single response + # then shut down the server. + server, options = await create_mcp_server(hass, context, llm_api_id) + + async with create_streams() as streams: + + async def run_server() -> None: + await server.run( + streams.read_stream, streams.write_stream, options, stateless=True + ) + + async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg: + tg.start_soon(run_server) + + await streams.read_stream_writer.send(SessionMessage(message)) + session_message = await anext(streams.write_stream_reader) + tg.cancel_scope.cancel() + + _LOGGER.debug("Sending response: %s", session_message) + return web.json_response( + data=session_message.message.model_dump(by_alias=True, exclude_none=True), + ) + + class ModelContextProtocolStreamableView(HomeAssistantView): - """Model Context Protocol Streamable HTTP endpoint.""" + """Model Context Protocol Streamable HTTP endpoint. + + This serves the configured LLM APIs and does not require admin access. + """ name = f"{DOMAIN}:streamable" url = STREAMABLE_API - async def get(self, request: web.Request) -> web.StreamResponse: - """Handle unsupported methods.""" - return web.Response( - status=HTTPStatus.METHOD_NOT_ALLOWED, text="Only POST method is supported" - ) - async def post(self, request: web.Request) -> web.StreamResponse: - """Process JSON-RPC messages for the Model Context Protocol.""" + """Process JSON-RPC messages for the configured LLM APIs.""" hass = request.app[KEY_HASS] entry = async_get_config_entry(hass) + return await _async_handle_streamable_message( + request, self.context(request), entry.data[CONF_LLM_HASS_API] + ) - # The request must include a JSON-RPC message - if CONTENT_TYPE_JSON not in request.headers.get("accept", ""): - raise HTTPBadRequest(text=f"Client must accept {CONTENT_TYPE_JSON}") - if request.content_type != CONTENT_TYPE_JSON: - raise HTTPBadRequest(text=f"Content-Type must be {CONTENT_TYPE_JSON}") - try: - json_data = await request.json() - message = types.JSONRPCMessage.model_validate(json_data) - except ValueError as err: - _LOGGER.debug("Failed to parse message as JSON-RPC message: %s", err) - raise HTTPBadRequest(text="Request must be a JSON-RPC message") from err - _LOGGER.debug("Received client message: %s", message) +class ModelContextProtocolStreamableApiView(HomeAssistantView): + """Model Context Protocol Streamable HTTP endpoint for a specific LLM API. - # For notifications and responses only, return 202 Accepted - if not isinstance(message.root, JSONRPCRequest): - _LOGGER.debug("Notification or response received, returning 202") - return web.Response(status=HTTPStatus.ACCEPTED) + The LLM API is selected by its ID in the URL. These endpoints require + admin access, except for the Assist API. + """ - # The MCP server runs as a background task for the duration of the - # request. We open a buffered stream pair to communicate with it. The - # request is sent to the MCP server and we wait for a single response - # then shut down the server. - server, options = await create_mcp_server(hass, self.context(request), entry) + name = f"{DOMAIN}:streamable_api" + url = f"{STREAMABLE_API}/{{api_id}}" - async with create_streams() as streams: - - async def run_server() -> None: - await server.run( - streams.read_stream, streams.write_stream, options, stateless=True - ) - - async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg: - tg.start_soon(run_server) - - await streams.read_stream_writer.send(SessionMessage(message)) - session_message = await anext(streams.write_stream_reader) - tg.cancel_scope.cancel() - - _LOGGER.debug("Sending response: %s", session_message) - return web.json_response( - data=session_message.message.model_dump( - by_alias=True, exclude_none=True - ), - ) + async def post(self, request: web.Request, api_id: str) -> web.StreamResponse: + """Process JSON-RPC messages for the LLM API identified by api_id.""" + hass = request.app[KEY_HASS] + if api_id != llm.LLM_API_ASSIST and not request["hass_user"].is_admin: + raise Unauthorized + if api_id not in {api.id for api in llm.async_get_apis(hass)}: + raise HTTPNotFound(text=f"Unknown LLM API '{api_id}'") + return await _async_handle_streamable_message( + request, self.context(request), api_id + ) diff --git a/homeassistant/components/media_player/llm.py b/homeassistant/components/media_player/llm.py new file mode 100644 index 000000000000..aa6778835a64 --- /dev/null +++ b/homeassistant/components/media_player/llm.py @@ -0,0 +1,58 @@ +"""LLM tools for the media_player integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import ( + DOMAIN, + INTENT_MEDIA_NEXT, + INTENT_MEDIA_PAUSE, + INTENT_MEDIA_PREVIOUS, + INTENT_MEDIA_SEARCH_AND_PLAY, + INTENT_MEDIA_UNPAUSE, + INTENT_PLAYER_MUTE, + INTENT_PLAYER_UNMUTE, + INTENT_SET_VOLUME, + INTENT_SET_VOLUME_RELATIVE, +) + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = ( + INTENT_MEDIA_NEXT, + INTENT_MEDIA_PAUSE, + INTENT_PLAYER_MUTE, + INTENT_PLAYER_UNMUTE, + INTENT_MEDIA_PREVIOUS, + INTENT_MEDIA_SEARCH_AND_PLAY, + INTENT_MEDIA_UNPAUSE, + INTENT_SET_VOLUME, + INTENT_SET_VOLUME_RELATIVE, +) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index e2d30db21004..030f68bf4142 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -20,7 +20,7 @@ from .const import ( URI_SCHEME_REGEX, ) from .error import MediaSourceError, Unresolvable -from .helper import async_browse_media, async_resolve_media +from .helper import async_browse_media, async_resolve_media, async_search_media from .models import ( BrowseMediaSource, MediaSource, @@ -42,6 +42,7 @@ __all__ = [ "Unresolvable", "async_browse_media", "async_resolve_media", + "async_search_media", "generate_media_source_id", "is_media_source_id", ] diff --git a/homeassistant/components/media_source/helper.py b/homeassistant/components/media_source/helper.py index 0d7afc2b81c3..099774deaa8d 100644 --- a/homeassistant/components/media_source/helper.py +++ b/homeassistant/components/media_source/helper.py @@ -2,7 +2,12 @@ from collections.abc import Callable -from homeassistant.components.media_player import BrowseError, BrowseMedia +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + SearchMedia, + SearchMediaQuery, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.frame import report_usage from homeassistant.helpers.typing import UNDEFINED, UndefinedType @@ -67,6 +72,34 @@ async def async_browse_media( return item +async def async_search_media( + hass: HomeAssistant, + media_content_id: str | None, + query: SearchMediaQuery, +) -> SearchMedia: + """Return media searched in the media source.""" + if DOMAIN not in hass.data: + raise BrowseError("Media Source not loaded") + + try: + return await _get_media_item(hass, media_content_id, None).async_search(query) + except NotImplementedError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="search_not_supported", + translation_placeholders={"media_content_id": str(media_content_id)}, + ) from err + except ValueError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="search_media_failed", + translation_placeholders={ + "media_content_id": str(media_content_id), + "error": str(err), + }, + ) from err + + async def async_resolve_media( hass: HomeAssistant, media_content_id: str, diff --git a/homeassistant/components/media_source/http.py b/homeassistant/components/media_source/http.py index c1c4882e7acf..0acec90fae88 100644 --- a/homeassistant/components/media_source/http.py +++ b/homeassistant/components/media_source/http.py @@ -7,20 +7,25 @@ import voluptuous as vol from homeassistant.components import frontend, websocket_api from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_FILTER_CLASSES, + ATTR_MEDIA_SEARCH_QUERY, CONTENT_AUTH_EXPIRY_TIME, BrowseError, + MediaClass, + SearchMediaQuery, async_process_play_media_url, ) from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant from .error import Unresolvable -from .helper import async_browse_media, async_resolve_media +from .helper import async_browse_media, async_resolve_media, async_search_media def async_setup(hass: HomeAssistant) -> None: """Set up the HTTP views and WebSocket commands for media sources.""" websocket_api.async_register_command(hass, websocket_browse_media) + websocket_api.async_register_command(hass, websocket_search_media) websocket_api.async_register_command(hass, websocket_resolve_media) frontend.async_register_built_in_panel( hass, "media-browser", "media_browser", "mdi:play-box-multiple" @@ -48,6 +53,35 @@ async def websocket_browse_media( connection.send_error(msg["id"], "browse_media_failed", str(err)) +@websocket_api.websocket_command( + { + vol.Required("type"): "media_source/search_media", + vol.Optional(ATTR_MEDIA_CONTENT_ID, default=""): str, + vol.Required(ATTR_MEDIA_SEARCH_QUERY): str, + vol.Optional(ATTR_MEDIA_FILTER_CLASSES): [vol.Coerce(MediaClass)], + } +) +@websocket_api.async_response +async def websocket_search_media( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Search available media.""" + try: + result = await async_search_media( + hass, + msg["media_content_id"], + SearchMediaQuery( + search_query=msg[ATTR_MEDIA_SEARCH_QUERY], + media_filter_classes=msg.get(ATTR_MEDIA_FILTER_CLASSES), + ), + ) + except BrowseError as err: + connection.send_error(msg["id"], "search_media_failed", str(err)) + return + + connection.send_result(msg["id"], result.as_dict()) + + @websocket_api.websocket_command( { vol.Required("type"): "media_source/resolve_media", diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 3c4bd81c2aee..5d9ca3fb581e 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -13,7 +13,13 @@ import voluptuous as vol from homeassistant.components import http, websocket_api from homeassistant.components.http import require_admin -from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + SearchMedia, + SearchMediaQuery, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path @@ -23,6 +29,7 @@ from .error import Unresolvable from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia MAX_UPLOAD_SIZE = 1024 * 1024 * 20 +MAX_SEARCH_RESULTS = 100 LOGGER = logging.getLogger(__name__) @@ -175,6 +182,72 @@ class LocalSource(MediaSource): self._browse_media, source_dir_id, location ) + @override + async def async_search_media( + self, item: MediaSourceItem, query: SearchMediaQuery + ) -> SearchMedia: + """Search media by file name within the local media directories.""" + if item.identifier: + try: + source_dir_id, location = self.async_parse_identifier(item) + except Unresolvable as err: + raise BrowseError(str(err)) from err + search_dirs = [(source_dir_id, location)] + else: + search_dirs = [(source_dir_id, "") for source_dir_id in self.media_dirs] + + return await self.hass.async_add_executor_job( + self._search_media, search_dirs, query + ) + + def _search_media( + self, search_dirs: list[tuple[str, str]], query: SearchMediaQuery + ) -> SearchMedia: + """Search media files by name (runs in the executor).""" + query_str = query.search_query.casefold() + filter_classes = set(query.media_filter_classes or ()) + results: list[BrowseMedia] = [] + + for source_dir_id, location in search_dirs: + if len(results) >= MAX_SEARCH_RESULTS: + break + base_path = Path(self.media_dirs[source_dir_id]) + search_path = base_path / location + if not search_path.is_dir(): + continue + + # Traverse lazily so MAX_SEARCH_RESULTS can short-circuit large libraries + for path in search_path.rglob("*"): + if len(results) >= MAX_SEARCH_RESULTS: + break + relative = path.relative_to(base_path) + if any(part.startswith(".") for part in relative.parts): + continue + if query_str not in path.name.casefold() or not path.is_file(): + continue + mime_type, _ = mimetypes.guess_type(str(path)) + if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES: + continue + media_class = MEDIA_CLASS_MAP.get( + mime_type.split("/")[0], MediaClass.DIRECTORY + ) + if filter_classes and media_class not in filter_classes: + continue + results.append( + BrowseMediaSource( + domain=self.domain, + identifier=f"{source_dir_id}/{relative}", + media_class=media_class, + media_content_type=mime_type, + title=path.name, + can_play=True, + can_expand=False, + ) + ) + + results.sort(key=lambda item: item.title) + return SearchMedia(result=results) + def _browse_media( self, source_dir_id: str | None, location: str ) -> BrowseMediaSource: @@ -197,6 +270,7 @@ class LocalSource(MediaSource): title=self.name, can_play=False, can_expand=True, + can_search=True, children_media_class=MediaClass.DIRECTORY, ) @@ -255,6 +329,7 @@ class LocalSource(MediaSource): title=title, can_play=is_file, can_expand=is_dir, + can_search=is_dir, ) if is_file or is_child: diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index c02cee7b9b95..fdf34c9f594b 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -3,7 +3,13 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any -from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType +from homeassistant.components.media_player import ( + BrowseMedia, + MediaClass, + MediaType, + SearchMedia, + SearchMediaQuery, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.translation import async_get_cached_translations @@ -101,6 +107,15 @@ class MediaSourceItem: return await self.async_media_source().async_browse_media(self) + async def async_search(self, query: SearchMediaQuery) -> SearchMedia: + """Search this item.""" + # Searching the aggregate root (no specific source) is currently not supported + # because it would possibly returns 100s of items + if self.domain is None: + raise NotImplementedError + + return await self.async_media_source().async_search_media(self, query) + async def async_resolve(self) -> PlayMedia: """Resolve to playable item.""" return await self.async_media_source().async_resolve_media(self) @@ -144,3 +159,9 @@ class MediaSource: async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: """Browse media.""" raise NotImplementedError + + async def async_search_media( + self, item: MediaSourceItem, query: SearchMediaQuery + ) -> SearchMedia: + """Search media.""" + raise NotImplementedError diff --git a/homeassistant/components/media_source/strings.json b/homeassistant/components/media_source/strings.json index 607f48f66523..9755af5f8a49 100644 --- a/homeassistant/components/media_source/strings.json +++ b/homeassistant/components/media_source/strings.json @@ -4,10 +4,16 @@ }, "exceptions": { "browse_media_failed": { - "message": "Failed to browse media with content id {media_content_id}: {error}" + "message": "Failed to browse media with content ID {media_content_id}: {error}" }, "resolve_media_failed": { - "message": "Failed to resolve media with content id {media_content_id}: {error}" + "message": "Failed to resolve media with content ID {media_content_id}: {error}" + }, + "search_media_failed": { + "message": "Failed to search media with content ID {media_content_id}: {error}" + }, + "search_not_supported": { + "message": "Search is not supported for media with content ID {media_content_id}" }, "unknown_media_source": { "message": "Unknown media source: {domain}" diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 4e17653c05aa..f4025bf10079 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -1,27 +1,36 @@ """The Mikrotik component.""" +from typing import Any + +from librouteros import Api + from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import ATTR_MANUFACTURER, DOMAIN -from .coordinator import MikrotikConfigEntry, MikrotikDataUpdateCoordinator, get_api -from .errors import CannotConnect, LoginError +from .coordinator import ( + MikrotikConfigEntry, + MikrotikDataUpdateCoordinator, + get_api, + mikrotik_config_entry_errors, +) PLATFORMS = [Platform.DEVICE_TRACKER] +def _call_api(data: dict[str, Any]) -> Api: + """Call the Mikrotik API.""" + with mikrotik_config_entry_errors(): + api: Api = get_api(data) + return api + + async def async_setup_entry( hass: HomeAssistant, config_entry: MikrotikConfigEntry ) -> bool: """Set up the Mikrotik component.""" - try: - api = await hass.async_add_executor_job(get_api, dict(config_entry.data)) - except CannotConnect as api_error: - raise ConfigEntryNotReady from api_error - except LoginError as err: - raise ConfigEntryAuthFailed from err + api = await hass.async_add_executor_job(_call_api, dict(config_entry.data)) coordinator = MikrotikDataUpdateCoordinator(hass, config_entry, api) await hass.async_add_executor_job(coordinator.api.get_hub_details) diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index 7c4249694881..902681670388 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -17,8 +17,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( ARP, @@ -45,6 +44,7 @@ from .const import ( ) from .device import Device from .errors import CannotConnect, LoginError +from .utils import mikrotik_config_entry_errors _LOGGER = logging.getLogger(__name__) @@ -134,7 +134,11 @@ class MikrotikData: arp_devices = {} device_list = {} wireless_devices = {} - try: + with mikrotik_config_entry_errors(): + # Check if connection/login are still valid + self.api = get_api(dict(self.config_entry.data)) + + # Retrieve data self.all_devices = self.get_list_from_interface(DHCP) if self.support_capsman: _LOGGER.debug("Hub is a CAPSman manager") @@ -160,11 +164,6 @@ class MikrotikData: # get new hub firmware version if updated self.firmware = self.get_info(ATTR_FIRMWARE) - except CannotConnect as err: - raise UpdateFailed from err - except LoginError as err: - raise ConfigEntryAuthFailed from err - if not device_list: return @@ -225,27 +224,12 @@ class MikrotikData: ) -> list[dict[str, Any]]: """Retrieve data from Mikrotik API.""" _LOGGER.debug("Running command %s", cmd) - try: + with mikrotik_config_entry_errors( + suppress_errors=suppress_errors, host=self._host + ): if params: return list(self.api(cmd, **params)) return list(self.api(cmd)) - except ( - librouteros.exceptions.ConnectionClosed, - OSError, - TimeoutError, - ) as api_error: - _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) - # try to reconnect - self.api = get_api(dict(self.config_entry.data)) - # we still have to raise CannotConnect to fail the update. - raise CannotConnect from api_error - except librouteros.exceptions.ProtocolError as api_error: - emsg = "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s" - if suppress_errors and "no such command prefix" in str(api_error): - _LOGGER.debug(emsg, self._host, cmd, api_error) - return [] - _LOGGER.warning(emsg, self._host, cmd, api_error) - return [] class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]): diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index df1883c5bc42..99a94d485f08 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -30,6 +30,23 @@ } } }, + "exceptions": { + "cannot_connect": { + "message": "Error connecting: {error}" + }, + "cannot_login": { + "message": "Cannot login: {error}" + }, + "cannot_retrieve_data": { + "message": "Error retrieving data: {error}" + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "mikrotik_api_error": { + "message": "Mikrotik API error: {error}" + } + }, "options": { "step": { "device_tracker": { diff --git a/homeassistant/components/mikrotik/utils.py b/homeassistant/components/mikrotik/utils.py new file mode 100644 index 000000000000..46fe6d0beff0 --- /dev/null +++ b/homeassistant/components/mikrotik/utils.py @@ -0,0 +1,49 @@ +"""Utils for Mikrotik.""" + +from collections.abc import Generator +from contextlib import contextmanager + +from librouteros.exceptions import ConnectionClosed, LibRouterosError + +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) + +from .const import DOMAIN +from .errors import CannotConnect, LoginError + + +@contextmanager +def mikrotik_config_entry_errors( + suppress_errors: bool = False, host: str | None = None +) -> Generator[None]: + """Handle common Mikrotik API exceptions as ConfigEntry errors.""" + try: + yield + except LoginError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except (CannotConnect, OSError, TimeoutError, ConnectionClosed) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except LibRouterosError as err: + if not suppress_errors: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="mikrotik_api_error", + translation_placeholders={"error": repr(err)}, + ) from err + + if "no such command prefix" not in str(err): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="mikrotik_api_error", + translation_placeholders={"error": repr(err)}, + ) from err diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index fe07132ff569..e6e49e05f5de 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -8,7 +8,9 @@ from mill_local import Mill as MillLocal from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL from .coordinator import ( @@ -16,11 +18,20 @@ from .coordinator import ( MillDataUpdateCoordinator, MillHistoricDataUpdateCoordinator, ) +from .services import async_setup_services PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] __all__ = ["CLOUD", "CONNECTION_TYPE", "DOMAIN", "LOCAL"] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Mill integration.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool: """Set up the Mill heater.""" diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 2e4cc1289f3a..fc0375b714bf 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -4,7 +4,6 @@ from typing import Any, override import mill from mill_local import OperationMode -import voluptuous as vol from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -14,37 +13,15 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_AWAY_TEMP, - ATTR_COMFORT_TEMP, - ATTR_ROOM_NAME, - ATTR_SLEEP_TEMP, - CONNECTION_TYPE, - DOMAIN, - LOCAL, - MANUFACTURER, - MAX_TEMP, - MIN_TEMP, - SERVICE_SET_ROOM_TEMP, -) +from .const import CONNECTION_TYPE, LOCAL, MANUFACTURER, MAX_TEMP, MIN_TEMP from .coordinator import MillConfigEntry, MillDataUpdateCoordinator from .entity import MillBaseEntity -SET_ROOM_TEMP_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ROOM_NAME): cv.string, - vol.Optional(ATTR_AWAY_TEMP): cv.positive_int, - vol.Optional(ATTR_COMFORT_TEMP): cv.positive_int, - vol.Optional(ATTR_SLEEP_TEMP): cv.positive_int, - } -) - async def async_setup_entry( hass: HomeAssistant, @@ -65,21 +42,6 @@ async def async_setup_entry( ] async_add_entities(entities) - async def set_room_temp(service: ServiceCall) -> None: - """Set room temp.""" - room_name = service.data.get(ATTR_ROOM_NAME) - sleep_temp = service.data.get(ATTR_SLEEP_TEMP) - comfort_temp = service.data.get(ATTR_COMFORT_TEMP) - away_temp = service.data.get(ATTR_AWAY_TEMP) - await mill_data_coordinator.mill_data_connection.set_room_temperatures_by_name( - room_name, sleep_temp, comfort_temp, away_temp - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_SET_ROOM_TEMP, set_room_temp, schema=SET_ROOM_TEMP_SCHEMA - ) - class MillHeater(MillBaseEntity, ClimateEntity): """Representation of a Mill Thermostat device.""" diff --git a/homeassistant/components/mill/services.py b/homeassistant/components/mill/services.py new file mode 100644 index 000000000000..a973fc59416b --- /dev/null +++ b/homeassistant/components/mill/services.py @@ -0,0 +1,45 @@ +"""Services for the Mill integration.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import ( + ATTR_AWAY_TEMP, + ATTR_COMFORT_TEMP, + ATTR_ROOM_NAME, + ATTR_SLEEP_TEMP, + DOMAIN, + SERVICE_SET_ROOM_TEMP, +) +from .coordinator import MillConfigEntry + +SET_ROOM_TEMP_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ROOM_NAME): cv.string, + vol.Optional(ATTR_AWAY_TEMP): cv.positive_int, + vol.Optional(ATTR_COMFORT_TEMP): cv.positive_int, + vol.Optional(ATTR_SLEEP_TEMP): cv.positive_int, + } +) + + +async def _set_room_temp(call: ServiceCall) -> None: + """Set room temp.""" + entry: MillConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + await entry.runtime_data.mill_data_connection.set_room_temperatures_by_name( + call.data[ATTR_ROOM_NAME], + call.data.get(ATTR_SLEEP_TEMP), + call.data.get(ATTR_COMFORT_TEMP), + call.data.get(ATTR_AWAY_TEMP), + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register Mill services.""" + + hass.services.async_register( + DOMAIN, SERVICE_SET_ROOM_TEMP, _set_room_temp, schema=SET_ROOM_TEMP_SCHEMA + ) diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index 06cf5d662a7b..8b72fc856690 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -11,7 +11,11 @@ from nextcloudmonitor import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from .const import DEFAULT_VERIFY_SSL, DOMAIN @@ -46,8 +50,7 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), ) - @override - async def async_step_user( + async def async_step_config( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" @@ -62,16 +65,37 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): except NextcloudMonitorConnectionError, NextcloudMonitorRequestError: errors["base"] = "connection_error" else: + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data_updates=user_input + ) return self.async_create_entry( title=user_input[CONF_URL], data=user_input, ) - data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, user_input) + data = user_input + if self.source == SOURCE_RECONFIGURE: + data = data or dict(self._get_reconfigure_entry().data) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, data) return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="config", data_schema=data_schema, errors=errors ) + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_config(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfigure flow initialized by the user.""" + return await self.async_step_config(user_input) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 373bd86b4f42..a4997e6e78e2 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "connection_error_during_import": "Connection error occurred during yaml configuration import", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "connection_error": "[%key:common::config_flow::error::cannot_connect%]", @@ -11,14 +12,7 @@ }, "flow_title": "Nextcloud", "step": { - "reauth_confirm": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "description": "Update your login information for {url}." - }, - "user": { + "config": { "data": { "password": "[%key:common::config_flow::data::password%]", "url": "[%key:common::config_flow::data::url%]", @@ -26,6 +20,13 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "description": "Enter your Nextcloud information." + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "description": "Update your login information for {url}." } } }, diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 27ad25a604bd..3cb4d01ce1b3 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -102,12 +102,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) client: OverkizClient | None = None api_type = entry.data.get(CONF_API_TYPE, APIType.CLOUD) - # Rexel Cloud API (OAuth2) - if entry.data.get(CONF_HUB) == Server.REXEL: - client = await create_rexel_client(hass, entry) - # Local API - elif api_type == APIType.LOCAL: + if api_type == APIType.LOCAL: client = create_local_client( hass, host=entry.data[CONF_HOST], @@ -115,6 +111,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) verify_ssl=entry.data[CONF_VERIFY_SSL], ) + # Rexel Cloud API (OAuth2) + elif entry.data.get(CONF_HUB) == Server.REXEL: + client = await create_rexel_client(hass, entry) + # Overkiz Cloud API else: client = create_cloud_client( diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index a006f9ef6363..57f4f5f7e012 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -132,6 +132,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> entry.runtime_data = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + return True @@ -273,3 +275,10 @@ def _async_fix_device_id( async def async_unload_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_listener( + hass: HomeAssistant, entry: RainbirdConfigEntry +) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 90393589263c..e405aadfe06e 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -7,7 +7,6 @@ import logging from typing import Any, NamedTuple, cast import RFXtrx as rfxtrxmod -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -20,7 +19,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import EventDeviceRegistryUpdatedData @@ -30,9 +29,9 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from .const import ( - ATTR_EVENT, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_PROTOCOLS, @@ -40,9 +39,9 @@ from .const import ( DEVICE_PACKET_TYPE_LIGHTING4, DOMAIN, EVENT_RFXTRX_EVENT, - SERVICE_SEND, SIGNAL_EVENT, ) +from .services import async_setup_services DEFAULT_OFF_DELAY = 2.0 @@ -59,18 +58,6 @@ class DeviceTuple(NamedTuple): id_string: str -def _bytearray_string(data: Any) -> bytearray: - val = cv.string(data) - try: - return bytearray.fromhex(val) - except ValueError as err: - raise vol.Invalid( - "Data must be a hex string with multiple of two characters" - ) from err - - -SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.COVER, @@ -81,6 +68,15 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up RFXtrx services.""" + hass.data.setdefault(DOMAIN, {}) + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the RFXtrx component.""" @@ -97,12 +93,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return False - hass.services.async_remove(DOMAIN, SERVICE_SEND) - rfx_object = hass.data[DOMAIN][DATA_RFXOBJECT] await hass.async_add_executor_job(rfx_object.close_connection) - hass.data.pop(DOMAIN) + hass.data[DOMAIN].pop(DATA_RFXOBJECT) return True @@ -284,13 +278,6 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) ) - def send(call: ServiceCall) -> None: - event = call.data[ATTR_EVENT] - rfx_object.transport.send(event) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) - async def async_setup_platform_entry( hass: HomeAssistant, diff --git a/homeassistant/components/rfxtrx/services.py b/homeassistant/components/rfxtrx/services.py new file mode 100644 index 000000000000..c1981dbc1224 --- /dev/null +++ b/homeassistant/components/rfxtrx/services.py @@ -0,0 +1,37 @@ +"""Support for RFXtrx services.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import ATTR_EVENT, DATA_RFXOBJECT, DOMAIN, SERVICE_SEND + + +def _bytearray_string(data: Any) -> bytearray: + val = cv.string(data) + try: + return bytearray.fromhex(val) + except ValueError as err: + raise vol.Invalid( + "Data must be a hex string with multiple of two characters" + ) from err + + +SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the RFXtrx services.""" + + def send(call: ServiceCall) -> None: + rfx_object = hass.data.get(DOMAIN, {}).get(DATA_RFXOBJECT) + if rfx_object is None: + raise HomeAssistantError("RFXtrx is not connected, cannot send event") + rfx_object.transport.send(call.data[ATTR_EVENT]) + + hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index c1126c552f49..449c0722bde1 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -239,10 +239,6 @@ async def async_migrate_entry( version = config_entry.version minor_version = config_entry.minor_version - if version > 2: - # This means the user has downgraded from a future version - return False - LOGGER.debug("Migrating from version %s.%s", version, minor_version) # 1 -> 2: Unique ID format changed, so delete and re-import: diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 214da9aacb16..2dd93008fd4d 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -327,6 +327,12 @@ class Searcher: # Add labels of this entity self._add(ItemType.LABEL, entity_entry.labels) + if not entry_point: + # If this entity also exists as a resource, we add it. + domain = split_entity_id(entity_id)[0] + if domain in self.EXIST_AS_ENTITY: + self._add(ItemType(domain), entity_id) + # Automations referencing this entity self._add( ItemType.AUTOMATION, @@ -394,14 +400,17 @@ class Searcher: # Areas with this label for area_entry in ar.async_entries_for_label(self._area_registry, label_id): self._add(ItemType.AREA, area_entry.id) + self._async_resolve_up_area(area_entry.id) # Devices with this label for device in dr.async_entries_for_label(self._device_registry, label_id): self._add(ItemType.DEVICE, device.id) + self._async_resolve_up_device(device.id) # Entities with this label for entity_entry in er.async_entries_for_label(self._entity_registry, label_id): self._add(ItemType.ENTITY, entity_entry.entity_id) + self._async_resolve_up_entity(entity_entry.entity_id) # If this entity also exists as a resource, we add it. domain = split_entity_id(entity_entry.entity_id)[0] @@ -420,7 +429,7 @@ class Searcher: @callback def _async_search_person(self, person_entity_id: str) -> None: """Find results for a person.""" - # Up resolve the scene entity itself + # Up resolve the person entity itself if entity_entry := self._async_resolve_up_entity(person_entity_id): # Add labels of this person entity self._add(ItemType.LABEL, entity_entry.labels) @@ -437,9 +446,9 @@ class Searcher: ) # Add all member entities of this person - self._add( - ItemType.ENTITY, person.entities_in_person(self.hass, person_entity_id) - ) + for entity_id in person.entities_in_person(self.hass, person_entity_id): + self._add(ItemType.ENTITY, entity_id) + self._async_resolve_up_entity(entity_id) @callback def _async_search_scene(self, scene_entity_id: str) -> None: diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 6473bde88ff3..54991bd5a2f1 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -79,7 +79,9 @@ __all__ = [ "RestoreSensor", "SensorDeviceClass", "SensorEntity", + "SensorEntityCapabilityAttribute", "SensorEntityDescription", + "SensorEntityStateAttribute", "SensorExtraStoredData", "SensorStateClass", ] diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 433ecb7eec68..0b3de1377ccf 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity import ( ) from homeassistant.helpers.typing import ConfigType -from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass +from . import DOMAIN, SensorDeviceClass, SensorEntityCapabilityAttribute DEVICE_CLASS_NONE = "none" @@ -246,7 +246,9 @@ async def async_get_conditions( for entry in entries: device_class = get_device_class(hass, entry.entity_id) or DEVICE_CLASS_NONE - state_class = get_capability(hass, entry.entity_id, ATTR_STATE_CLASS) + state_class = get_capability( + hass, entry.entity_id, SensorEntityCapabilityAttribute.STATE_CLASS + ) unit_of_measurement = get_unit_of_measurement(hass, entry.entity_id) if not unit_of_measurement and not state_class: diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 1d9fae637092..f3dbfa6b0161 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity import ( from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass +from . import DOMAIN, SensorDeviceClass, SensorEntityCapabilityAttribute DEVICE_CLASS_NONE = "none" @@ -276,7 +276,9 @@ async def async_get_triggers( for entry in entries: device_class = get_device_class(hass, entry.entity_id) or DEVICE_CLASS_NONE - state_class = get_capability(hass, entry.entity_id, ATTR_STATE_CLASS) + state_class = get_capability( + hass, entry.entity_id, SensorEntityCapabilityAttribute.STATE_CLASS + ) unit_of_measurement = get_unit_of_measurement(hass, entry.entity_id) if not unit_of_measurement and not state_class: diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 32326022220c..5440951454de 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -25,9 +25,8 @@ from homeassistant.components.recorder.models import ( StatisticResult, ) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, REVOLUTIONS_PER_MINUTE, + EntityStateAttribute, UnitOfIrradiance, UnitOfSoundPressure, UnitOfVolume, @@ -46,10 +45,10 @@ from homeassistant.util.unit_conversion import BaseUnitConverter from .const import ( AMBIGUOUS_UNITS, - ATTR_LAST_RESET, - ATTR_STATE_CLASS, DOMAIN, UNIT_CONVERTERS, + SensorEntityCapabilityAttribute, + SensorEntityStateAttribute, SensorStateClass, UnitOfVolumeFlowRate, ) @@ -114,7 +113,11 @@ def _get_sensor_states(hass: HomeAssistant) -> list[State]: return [ state for state in hass.states.all(DOMAIN) - if (state_class := state.attributes.get(ATTR_STATE_CLASS)) + if ( + state_class := state.attributes.get( + SensorEntityCapabilityAttribute.STATE_CLASS + ) + ) and ( type(state_class) is SensorStateClass or try_parse_enum(SensorStateClass, state_class) @@ -200,7 +203,10 @@ def _time_weighted_circular_mean( def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: """Return a set of all units.""" - return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} + return { + item[1].attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + for item in fstates + } def _equivalent_units( @@ -290,8 +296,8 @@ def _normalize_states( """Normalize units.""" state_unit: str | None = None statistics_unit: str | None - state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) - device_class = fstates[0][1].attributes.get(ATTR_DEVICE_CLASS) + state_unit = fstates[0][1].attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + device_class = fstates[0][1].attributes.get(EntityStateAttribute.DEVICE_CLASS) old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None equivalent_units_for_entity = _collect_equivalent_units_for_entity( custom_units_for_entity @@ -346,7 +352,7 @@ def _normalize_states( if state_unit != statistics_unit: unit_class = _get_unit_class( - fstates[0][1].attributes.get(ATTR_DEVICE_CLASS), + fstates[0][1].attributes.get(EntityStateAttribute.DEVICE_CLASS), state_unit, ) return unit_class, state_unit, fstates @@ -357,7 +363,7 @@ def _normalize_states( valid_units = converter.VALID_UNITS for fstate, state in fstates: - state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state_unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) # Exclude states with unsupported unit from statistics if state_unit not in valid_units: if WARN_UNSUPPORTED_UNIT not in hass.data: @@ -490,7 +496,9 @@ def reset_detected( def _wanted_statistics(sensor_states: list[State]) -> dict[str, _StatisticsConfig]: """Prepare a dict with wanted statistics for entities.""" return { - state.entity_id: DEFAULT_STATISTICS[state.attributes[ATTR_STATE_CLASS]] + state.entity_id: DEFAULT_STATISTICS[ + state.attributes[SensorEntityCapabilityAttribute.STATE_CLASS] + ] for state in sensor_states } @@ -601,7 +609,9 @@ def compile_statistics( # noqa: C901 ) if not valid_float_states: continue - state_class: str = _state.attributes[ATTR_STATE_CLASS] + state_class: str = _state.attributes[ + SensorEntityCapabilityAttribute.STATE_CLASS + ] to_process.append( (entity_id, unit_class, statistics_unit, state_class, valid_float_states) ) @@ -731,7 +741,8 @@ def compile_statistics( # noqa: C901 state_class != SensorStateClass.TOTAL_INCREASING and ( last_reset := _last_reset_as_utc_isoformat( - state.attributes.get("last_reset"), entity_id + state.attributes.get(SensorEntityStateAttribute.LAST_RESET), + entity_id, ) ) != old_last_reset @@ -831,7 +842,7 @@ def list_statistic_ids( continue attributes = state.attributes - state_class = attributes[ATTR_STATE_CLASS] + state_class = attributes[SensorEntityCapabilityAttribute.STATE_CLASS] provided_statistics = DEFAULT_STATISTICS[state_class] if ( statistic_type is not None @@ -841,7 +852,7 @@ def list_statistic_ids( if ( (has_sum := "sum" in provided_statistics.types) - and ATTR_LAST_RESET not in attributes + and SensorEntityStateAttribute.LAST_RESET not in attributes and state_class == SensorStateClass.MEASUREMENT ): continue @@ -850,8 +861,10 @@ def list_statistic_ids( if "mean" in provided_statistics.types: mean_type = provided_statistics.mean_type - unit = attributes.get(ATTR_UNIT_OF_MEASUREMENT) - unit_class = _get_unit_class(attributes.get(ATTR_DEVICE_CLASS), unit) + unit = attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + unit_class = _get_unit_class( + attributes.get(EntityStateAttribute.DEVICE_CLASS), unit + ) result[entity_id] = { "mean_type": mean_type, @@ -878,11 +891,12 @@ def _update_issues( entity_id = state.entity_id numeric = _is_numeric(state) state_class = try_parse_enum( - SensorStateClass, state.attributes.get(ATTR_STATE_CLASS) + SensorStateClass, + state.attributes.get(SensorEntityCapabilityAttribute.STATE_CLASS), ) - state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state_unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) state_unit_class = _get_unit_class( - state.attributes.get(ATTR_DEVICE_CLASS), + state.attributes.get(EntityStateAttribute.DEVICE_CLASS), state_unit, ) @@ -1048,7 +1062,8 @@ def validate_statistics( for state in sensor_states: entity_id = state.entity_id state_class = try_parse_enum( - SensorStateClass, state.attributes.get(ATTR_STATE_CLASS) + SensorStateClass, + state.attributes.get(SensorEntityCapabilityAttribute.STATE_CLASS), ) if entity_id in metadatas: diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py index 17497be74f64..57b543cb8fb7 100644 --- a/homeassistant/components/sensor/significant_change.py +++ b/homeassistant/components/sensor/significant_change.py @@ -2,11 +2,7 @@ from typing import Any -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, - UnitOfTemperature, -) +from homeassistant.const import EntityStateAttribute, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.significant_change import ( check_absolute_change, @@ -38,7 +34,7 @@ def async_check_significant_change( **kwargs: Any, ) -> bool | None: """Test if state significantly changed.""" - if (device_class := new_attrs.get(ATTR_DEVICE_CLASS)) is None: + if (device_class := new_attrs.get(EntityStateAttribute.DEVICE_CLASS)) is None: return None absolute_change: float | None = None @@ -47,7 +43,10 @@ def async_check_significant_change( SensorDeviceClass.TEMPERATURE, SensorDeviceClass.TEMPERATURE_DELTA, ): - if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT: + if ( + new_attrs.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + == UnitOfTemperature.FAHRENHEIT + ): absolute_change = 1.0 else: absolute_change = 0.5 diff --git a/homeassistant/components/signal_messenger/manifest.json b/homeassistant/components/signal_messenger/manifest.json index 5ff63052691f..5866ed07a304 100644 --- a/homeassistant/components/signal_messenger/manifest.json +++ b/homeassistant/components/signal_messenger/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["pysignalclirestapi"], "quality_scale": "legacy", - "requirements": ["pysignalclirestapi==0.3.24"] + "requirements": ["pysignalclirestapi==0.3.25"] } diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py index ca32ab5c64da..58532339f482 100644 --- a/homeassistant/components/signal_messenger/notify.py +++ b/homeassistant/components/signal_messenger/notify.py @@ -120,8 +120,8 @@ class SignalNotificationService(BaseNotificationService): self._signal_cli_rest_api.send_message( message, recipients, - filenames, - attachments_as_bytes, + filenames=filenames, + attachments_as_bytes=attachments_as_bytes, text_mode="normal" if data is None else data.get(ATTR_TEXTMODE), ) except SignalCliRestApiError as ex: diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index efbf973476bc..2279c14029b1 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -1,33 +1,29 @@ """Support for Streamlabs Water Monitor devices.""" from streamlabswater.streamlabswater import StreamlabsClient -import voluptuous as vol from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import StreamlabsConfigEntry, StreamlabsCoordinator - -ATTR_AWAY_MODE = "away_mode" -SERVICE_SET_AWAY_MODE = "set_away_mode" -AWAY_MODE_AWAY = "away" -AWAY_MODE_HOME = "home" - -CONF_LOCATION_ID = "location_id" +from .services import async_setup_services ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=streamlabswater"} -SET_AWAY_MODE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), - vol.Optional(CONF_LOCATION_ID): cv.string, - } -) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the integration.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) -> bool: """Set up StreamLabs from a config entry.""" @@ -41,17 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - def set_away_mode(service: ServiceCall) -> None: - """Set the StreamLabsWater Away Mode.""" - away_mode = service.data.get(ATTR_AWAY_MODE) - location_id = service.data.get(CONF_LOCATION_ID) or list(coordinator.data)[0] - client.update_location(location_id, away_mode) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA - ) - return True diff --git a/homeassistant/components/streamlabswater/services.py b/homeassistant/components/streamlabswater/services.py new file mode 100644 index 000000000000..f015f6365cf7 --- /dev/null +++ b/homeassistant/components/streamlabswater/services.py @@ -0,0 +1,44 @@ +"""Services for Streamlabs Water.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN +from .coordinator import StreamlabsConfigEntry + +ATTR_AWAY_MODE = "away_mode" +SERVICE_SET_AWAY_MODE = "set_away_mode" +AWAY_MODE_AWAY = "away" +AWAY_MODE_HOME = "home" + +CONF_LOCATION_ID = "location_id" + +SET_AWAY_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), + vol.Optional(CONF_LOCATION_ID): cv.string, + } +) + + +def set_away_mode(call: ServiceCall) -> None: + """Set the StreamLabsWater Away Mode.""" + entry: StreamlabsConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, None + ) + coordinator = entry.runtime_data + coordinator.client.update_location( + call.data.get(CONF_LOCATION_ID) or list(coordinator.data)[0], + call.data[ATTR_AWAY_MODE], + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + + hass.services.async_register( + DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA + ) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index face06ee6eb2..3223f24fa027 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -97,6 +97,7 @@ PLATFORMS_BY_TYPE = { SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR], SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR], SupportedModels.STANDING_FAN.value: [ + Platform.FAN, Platform.SELECT, Platform.NUMBER, Platform.SWITCH, @@ -426,9 +427,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) minor_version = entry.minor_version _LOGGER.debug("Migrating from version %s.%s", version, minor_version) - if version > 1: - return False - if version == 1 and minor_version < 2: new_options: dict[str, Any] = {**entry.options} diff --git a/homeassistant/components/switchbot/fan.py b/homeassistant/components/switchbot/fan.py index 3e42e6ed8b15..ee919f6b3ef2 100644 --- a/homeassistant/components/switchbot/fan.py +++ b/homeassistant/components/switchbot/fan.py @@ -1,16 +1,19 @@ """Support for SwitchBot Fans.""" +from collections.abc import Mapping import logging from typing import Any, override import switchbot -from switchbot import AirPurifierMode, FanMode +from switchbot import AirPurifierMode, FanMode, StandingFanMode from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from .const import DOMAIN from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity, exception_handler @@ -27,6 +30,8 @@ async def async_setup_entry( coordinator = entry.runtime_data if isinstance(coordinator.device, switchbot.SwitchbotAirPurifier): async_add_entities([SwitchBotAirPurifierEntity(coordinator)]) + elif isinstance(coordinator.device, switchbot.SwitchbotStandingFan): + async_add_entities([SwitchBotStandingFanEntity(coordinator)]) else: async_add_entities([SwitchBotFanEntity(coordinator)]) @@ -132,6 +137,98 @@ class SwitchBotFanEntity(SwitchbotEntity, FanEntity, RestoreEntity): self.async_write_ha_state() +class SwitchBotStandingFanEntity(SwitchBotFanEntity): + """Representation of a Switchbot Standing Fan.""" + + _device: switchbot.SwitchbotStandingFan + _attr_preset_modes = StandingFanMode.get_modes() + _attr_translation_key = "standing_fan" + + @property + @override + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return the state attributes. + + Failures surface as HomeAssistantError, so last_run_success is not used. + """ + return {} + + @exception_handler + @override + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + _LOGGER.debug( + "Switchbot standing fan to set preset mode %s %s", + preset_mode, + self._address, + ) + if not await self._device.set_preset_mode(preset_mode): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="set_fan_state_failed" + ) + self.async_write_ha_state() + + @exception_handler + @override + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + _LOGGER.debug( + "Switchbot standing fan to set percentage %d %s", percentage, self._address + ) + if not await self._device.set_percentage(percentage): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="set_fan_state_failed" + ) + self.async_write_ha_state() + + @exception_handler + @override + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + _LOGGER.debug( + "Switchbot standing fan to set oscillating %s %s", + oscillating, + self._address, + ) + if not await self._device.set_oscillation(oscillating): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="set_fan_state_failed" + ) + self.async_write_ha_state() + + @exception_handler + @override + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + _LOGGER.debug( + "Switchbot standing fan to set turn on %s %s %s", + percentage, + preset_mode, + self._address, + ) + if not await self._device.turn_on(): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="set_fan_state_failed" + ) + self.async_write_ha_state() + + @exception_handler + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + _LOGGER.debug("Switchbot standing fan to set turn off %s", self._address) + if not await self._device.turn_off(): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="set_fan_state_failed" + ) + self.async_write_ha_state() + + class SwitchBotAirPurifierEntity(SwitchbotEntity, FanEntity): """Representation of a Switchbot air purifier.""" diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index a30bb0d36a59..776316e7429d 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -187,6 +187,19 @@ } } } + }, + "standing_fan": { + "state_attributes": { + "preset_mode": { + "state": { + "baby": "[%key:component::switchbot::entity::fan::fan::state_attributes::preset_mode::state::baby%]", + "custom_natural": "Custom natural", + "natural": "[%key:component::switchbot::entity::fan::fan::state_attributes::preset_mode::state::natural%]", + "normal": "[%key:common::state::normal%]", + "sleep": "[%key:component::switchbot::entity::fan::fan::state_attributes::preset_mode::state::sleep%]" + } + } + } } }, "humidifier": { @@ -418,6 +431,9 @@ "operation_error": { "message": "An error occurred while performing the action: {error}" }, + "set_fan_state_failed": { + "message": "Failed to send the command to the fan." + }, "value_error": { "message": "Switchbot device initialization failed because of incorrect configuration parameters: {error}" } diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index dfab47d2f694..6fdf4a6b6b7f 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.4.7"] + "requirements": ["tesla-fleet-api==1.5.2"] } diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index c71638789d2b..95b4eabffbd6 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any, override -from tesla_fleet_api.const import AutoSeat, Scope, Seat +from tesla_fleet_api.const import AutoSeat, Scope from homeassistant.components.switch import ( SwitchDeviceClass, @@ -48,7 +48,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( AutoSeat.FRONT_LEFT, True ), off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_LEFT, False + AutoSeat.FRONT_LEFT, False ), scopes=[Scope.VEHICLE_CMDS], ), diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 2ad8a28fa88d..d56b3c6a30e0 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.4.7", "teslemetry-stream==0.9.1"] + "requirements": ["tesla-fleet-api==1.5.2", "teslemetry-stream==0.9.1"] } diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 2803c71ccf6b..36aed04573f7 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -176,6 +176,33 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = ( HIGH, ], ), + TeslemetrySelectEntityDescription( + # remote_seat_cooler_request uses 1-indexed positions (front-left=1, + # front-right=2), unlike the 0-indexed Seat enum used for heaters. + # Polled state comes from the seat_fan_front_* vehicle_data fields. + key="climate_state_seat_fan_front_left", + select_fn=lambda api, level: api.remote_seat_cooler_request(1, level), + supported_fn=lambda data: bool(data.get("has_seat_cooling")), + streaming_listener=lambda x, y: x.listen_ClimateSeatCoolingFrontLeft(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], + ), + TeslemetrySelectEntityDescription( + key="climate_state_seat_fan_front_right", + select_fn=lambda api, level: api.remote_seat_cooler_request(2, level), + supported_fn=lambda data: bool(data.get("has_seat_cooling")), + streaming_listener=lambda x, y: x.listen_ClimateSeatCoolingFrontRight(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], + ), ) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index ffb9e9d9ccbd..cc39dad96bef 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -363,6 +363,24 @@ } }, "select": { + "climate_state_seat_fan_front_left": { + "name": "Seat cooler front left", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, + "climate_state_seat_fan_front_right": { + "name": "Seat cooler front right", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index eb35db884ef8..7d653e6d4019 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.4.7"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.5.2"] } diff --git a/homeassistant/components/todo/llm.py b/homeassistant/components/todo/llm.py new file mode 100644 index 000000000000..ddfe261179c0 --- /dev/null +++ b/homeassistant/components/todo/llm.py @@ -0,0 +1,126 @@ +"""LLM tools for the todo integration.""" + +from operator import attrgetter +from typing import Any, cast, override + +import voluptuous as vol + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolInput, +) +from homeassistant.util.json import JsonObjectType + +from .const import DOMAIN, TodoServices +from .intent import ( + INTENT_LIST_ADD_ITEM, + INTENT_LIST_COMPLETE_ITEM, + INTENT_LIST_REMOVE_ITEM, +) + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_LIST_ADD_ITEM, INTENT_LIST_COMPLETE_ITEM, INTENT_LIST_REMOVE_ITEM) + + +class TodoGetItemsTool(Tool): + """LLM Tool allowing querying a to-do list.""" + + name = "todo_get_items" + description = ( + "Query a to-do list to find out what items are on it. " + "Use this to answer questions like " + "'What's on my task list?' or " + "'Read my grocery list'. " + "Filters items by status (needs_action, completed, all)." + ) + + def __init__(self, todo_lists: list[str]) -> None: + """Init the get items tool.""" + self.parameters = vol.Schema( + { + vol.Required("todo_list"): vol.In(todo_lists), + vol.Optional( + "status", + description=( + "Filter returned items by status," + " by default returns incomplete" + " items" + ), + default="needs_action", + ): vol.In(["needs_action", "completed", "all"]), + } + ) + + @override + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a to-do list.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["todo_list"], + domains=[DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "To-do list not found"} + entity_id = result.states[0].entity_id + service_data: dict[str, Any] = {"entity_id": entity_id} + if status := data.get("status"): + if status == "all": + service_data["status"] = ["needs_action", "completed"] + else: + service_data["status"] = [status] + service_result = await hass.services.async_call( + DOMAIN, + TodoServices.GET_ITEMS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + if not service_result: + return {"success": False, "error": "To-do list not found"} + items = cast(dict, service_result)[entity_id]["items"] + return {"success": True, "result": items} + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return the todo LLM tools when a to-do list is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + entity_registry = er.async_get(hass) + names: list[str] = [] + for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")): + if not async_should_expose(hass, llm_context.assistant, state.entity_id): + continue + entity_entry = entity_registry.async_get(state.entity_id) + names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state)) + + if not names: + return None + + tools: list[Tool] = [TodoGetItemsTool(names)] + tools.extend( + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ) + return LLMTools(tools=tools) diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index b32dcd94d7ff..4f65a0bfa60f 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -12,6 +12,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/v2c/icons.json b/homeassistant/components/v2c/icons.json index 88a890fcb4d3..962d0ea9c323 100644 --- a/homeassistant/components/v2c/icons.json +++ b/homeassistant/components/v2c/icons.json @@ -13,6 +13,11 @@ "default": "mdi:sine-wave" } }, + "select": { + "charge_mode": { + "default": "mdi:ev-plug-type2" + } + }, "sensor": { "battery_power": { "default": "mdi:home-battery" diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index 459a3260b6d3..ddad80b92f24 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/v2c", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pytrydan==1.0.2"] + "requirements": ["pytrydan==1.0.3"] } diff --git a/homeassistant/components/v2c/select.py b/homeassistant/components/v2c/select.py new file mode 100644 index 000000000000..174f312661dd --- /dev/null +++ b/homeassistant/components/v2c/select.py @@ -0,0 +1,98 @@ +"""Select platform for V2C settings.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, override + +from pytrydan import Trydan, TrydanData +from pytrydan.models.trydan import ChargeMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator +from .entity import V2CBaseEntity + + +def charge_mode_value(value: ChargeMode) -> str: + """Return the charge mode option value.""" + return value.name.lower() + + +@dataclass(frozen=True, kw_only=True) +class V2CSelectEntityDescription(SelectEntityDescription): + """Describes V2C EVSE select entity.""" + + current_option_fn: Callable[[TrydanData], str | None] + options: list[str] + update_fn: Callable[[Trydan, str], Coroutine[Any, Any, None]] + + +CHARGE_MODE_OPTIONS = [charge_mode_value(mode) for mode in ChargeMode] + +TRYDAN_SELECTS = ( + V2CSelectEntityDescription( + key="charge_mode", + translation_key="charge_mode", + entity_category=EntityCategory.CONFIG, + options=CHARGE_MODE_OPTIONS, + current_option_fn=lambda evse_data: ( + charge_mode_value(evse_data.charge_mode) + if evse_data.charge_mode is not None + else None + ), + update_fn=lambda evse, option: evse.charge_mode(ChargeMode[option.upper()]), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: V2CConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up V2C Trydan select platform.""" + coordinator = config_entry.runtime_data + data = coordinator.data + assert data is not None + + async_add_entities( + V2CSelectEntity( + coordinator, + description, + config_entry.entry_id, + ) + for description in TRYDAN_SELECTS + if description.current_option_fn(data) is not None + ) + + +class V2CSelectEntity(V2CBaseEntity, SelectEntity): + """Representation of V2C EVSE settings select entity.""" + + entity_description: V2CSelectEntityDescription + + def __init__( + self, + coordinator: V2CUpdateCoordinator, + description: V2CSelectEntityDescription, + entry_id: str, + ) -> None: + """Initialize the V2C select entity.""" + super().__init__(coordinator, description) + self._attr_unique_id = f"{entry_id}_{description.key}" + self._attr_options = description.options + + @property + @override + def current_option(self) -> str | None: + """Return the current charge mode.""" + return self.entity_description.current_option_fn(self.data) + + @override + async def async_select_option(self, option: str) -> None: + """Update the setting.""" + await self.entity_description.update_fn(self.coordinator.evse, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index b3abf95d0d8e..ef3b10074655 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -52,6 +52,16 @@ "name": "Installation voltage" } }, + "select": { + "charge_mode": { + "name": "Charge mode", + "state": { + "mixed": "Mixed", + "monophasic": "Monophasic", + "threephasic": "Three-phase" + } + } + }, "sensor": { "battery_power": { "name": "Battery power" diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 14291de108f4..e09cc48a639b 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -188,7 +188,9 @@ class StateVacuumEntity( entity_description: StateVacuumEntityDescription - _entity_component_unrecorded_attributes = frozenset({ATTR_FAN_SPEED_LIST}) + _entity_component_unrecorded_attributes = frozenset( + {VacuumEntityCapabilityAttribute.FAN_SPEED_LIST} + ) _attr_battery_icon: str _attr_battery_level: int | None = None diff --git a/homeassistant/components/vacuum/llm.py b/homeassistant/components/vacuum/llm.py new file mode 100644 index 000000000000..eb28c4a9ca25 --- /dev/null +++ b/homeassistant/components/vacuum/llm.py @@ -0,0 +1,46 @@ +"""LLM tools for the vacuum integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import ( + INTENT_VACUUM_CLEAN_AREA, + INTENT_VACUUM_RETURN_TO_BASE, + INTENT_VACUUM_START, +) + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = ( + INTENT_VACUUM_CLEAN_AREA, + INTENT_VACUUM_RETURN_TO_BASE, + INTENT_VACUUM_START, +) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/vacuum/reproduce_state.py b/homeassistant/components/vacuum/reproduce_state.py index 779cc698774b..cd93309d1445 100644 --- a/homeassistant/components/vacuum/reproduce_state.py +++ b/homeassistant/components/vacuum/reproduce_state.py @@ -15,7 +15,6 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, State from . import ( - ATTR_FAN_SPEED, DOMAIN, SERVICE_PAUSE, SERVICE_RETURN_TO_BASE, @@ -24,6 +23,7 @@ from . import ( SERVICE_STOP, VacuumActivity, ) +from .const import VacuumEntityStateAttribute _LOGGER = logging.getLogger(__name__) @@ -57,8 +57,8 @@ async def _async_reproduce_state( # Return if we are already at the right state. if cur_state.state == state.state and cur_state.attributes.get( - ATTR_FAN_SPEED - ) == state.attributes.get(ATTR_FAN_SPEED): + VacuumEntityStateAttribute.FAN_SPEED + ) == state.attributes.get(VacuumEntityStateAttribute.FAN_SPEED): return service_data = {ATTR_ENTITY_ID: state.entity_id} @@ -82,9 +82,13 @@ async def _async_reproduce_state( DOMAIN, service, service_data, context=context, blocking=True ) - if cur_state.attributes.get(ATTR_FAN_SPEED) != state.attributes.get(ATTR_FAN_SPEED): + if cur_state.attributes.get( + VacuumEntityStateAttribute.FAN_SPEED + ) != state.attributes.get(VacuumEntityStateAttribute.FAN_SPEED): # Wrong fan speed - service_data["fan_speed"] = state.attributes[ATTR_FAN_SPEED] + service_data["fan_speed"] = state.attributes[ + VacuumEntityStateAttribute.FAN_SPEED + ] await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_SPEED, service_data, context=context, blocking=True ) diff --git a/homeassistant/components/vacuum/significant_change.py b/homeassistant/components/vacuum/significant_change.py index c57711c042dd..1741cb2588c7 100644 --- a/homeassistant/components/vacuum/significant_change.py +++ b/homeassistant/components/vacuum/significant_change.py @@ -8,11 +8,12 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from . import ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED +from . import ATTR_BATTERY_LEVEL +from .const import VacuumEntityStateAttribute SIGNIFICANT_ATTRIBUTES: set[str] = { ATTR_BATTERY_LEVEL, - ATTR_FAN_SPEED, + VacuumEntityStateAttribute.FAN_SPEED, } diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py index f13fd9e3a94e..511cf3b09f97 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -27,6 +27,7 @@ from .const import ( # noqa: F401 DOMAIN, ValveDeviceClass, ValveEntityFeature, + ValveEntityStateAttribute, ValveState, ) from .entity import ( # noqa: F401 diff --git a/homeassistant/components/valve/condition.py b/homeassistant/components/valve/condition.py index 0575c678c217..c1709ab40e02 100644 --- a/homeassistant/components/valve/condition.py +++ b/homeassistant/components/valve/condition.py @@ -4,11 +4,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import Condition, make_entity_state_condition -from . import ATTR_IS_CLOSED -from .const import DOMAIN +from .const import DOMAIN, ValveEntityStateAttribute VALVE_DOMAIN_SPECS: dict[str, DomainSpec] = { - DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED), + DOMAIN: DomainSpec(value_source=ValveEntityStateAttribute.IS_CLOSED), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/valve/trigger.py b/homeassistant/components/valve/trigger.py index b3fa7aa4cfe5..108fa3a7fde4 100644 --- a/homeassistant/components/valve/trigger.py +++ b/homeassistant/components/valve/trigger.py @@ -4,10 +4,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger -from . import ATTR_IS_CLOSED, DOMAIN +from .const import DOMAIN, ValveEntityStateAttribute VALVE_DOMAIN_SPECS: dict[str, DomainSpec] = { - DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED), + DOMAIN: DomainSpec(value_source=ValveEntityStateAttribute.IS_CLOSED), } diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index 10a9662f1cd1..dc81bb9aeb9b 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["victron-mqtt==2026.6.1.1"], + "requirements": ["victron-mqtt==2026.6.6"], "ssdp": [ { "X_MqttOnLan": "1", diff --git a/homeassistant/components/victron_gx/number.py b/homeassistant/components/victron_gx/number.py index b1f35c91bc28..733789152c51 100644 --- a/homeassistant/components/victron_gx/number.py +++ b/homeassistant/components/victron_gx/number.py @@ -29,6 +29,11 @@ METRIC_TYPE_TO_DEVICE_CLASS: dict[MetricType, NumberDeviceClass] = { MetricType.FREQUENCY: NumberDeviceClass.FREQUENCY, MetricType.ELECTRIC_STORAGE_PERCENTAGE: NumberDeviceClass.BATTERY, MetricType.TEMPERATURE: NumberDeviceClass.TEMPERATURE, + MetricType.HUMIDITY: NumberDeviceClass.HUMIDITY, + MetricType.PRESSURE: NumberDeviceClass.PRESSURE, + MetricType.DISTANCE: NumberDeviceClass.DISTANCE, + MetricType.POWER_FACTOR: NumberDeviceClass.POWER_FACTOR, + MetricType.COST: NumberDeviceClass.MONETARY, MetricType.SPEED: NumberDeviceClass.SPEED, MetricType.LIQUID_VOLUME: NumberDeviceClass.VOLUME_STORAGE, MetricType.DURATION: NumberDeviceClass.DURATION, diff --git a/homeassistant/components/victron_gx/sensor.py b/homeassistant/components/victron_gx/sensor.py index 074cc2941f81..891a0344ca61 100644 --- a/homeassistant/components/victron_gx/sensor.py +++ b/homeassistant/components/victron_gx/sensor.py @@ -34,6 +34,11 @@ METRIC_TYPE_TO_DEVICE_CLASS: dict[MetricType, SensorDeviceClass] = { MetricType.FREQUENCY: SensorDeviceClass.FREQUENCY, MetricType.ELECTRIC_STORAGE_PERCENTAGE: SensorDeviceClass.BATTERY, MetricType.TEMPERATURE: SensorDeviceClass.TEMPERATURE, + MetricType.HUMIDITY: SensorDeviceClass.HUMIDITY, + MetricType.PRESSURE: SensorDeviceClass.PRESSURE, + MetricType.DISTANCE: SensorDeviceClass.DISTANCE, + MetricType.POWER_FACTOR: SensorDeviceClass.POWER_FACTOR, + MetricType.COST: SensorDeviceClass.MONETARY, MetricType.SPEED: SensorDeviceClass.SPEED, MetricType.LIQUID_VOLUME: SensorDeviceClass.VOLUME_STORAGE, MetricType.DURATION: SensorDeviceClass.DURATION, diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 4cb27e94c5a3..084bbc8bdc60 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -20,12 +20,14 @@ "current": "Current", "current_limit": "Current limit", "current_on_phase": "Current on {phase}", - "current_phase": "Current {phase}", "current_sensor_issue": "Current sensor issue", + "dc_current": "DC current", "dc_output_current": "DC output current", "dc_output_power": "DC output power", "dc_output_voltage": "DC output voltage", + "dc_power": "DC power", "dc_temperature": "DC temperature", + "dc_voltage": "DC voltage", "equalize": "Equalize", "error_code": "Error code", "ess_mode": "ESS mode", @@ -49,6 +51,7 @@ "lost_communication_with_device": "Lost communication with device", "low_battery_alarm": "Low battery alarm", "low_power": "Low power", + "low_temperature": "Low temperature", "max_power_today": "Max power today", "max_power_yesterday": "Max power yesterday", "mppt_active": "MPPT active", @@ -90,6 +93,7 @@ "synchronized_charging_config_issue": "Synchronized charging config issue", "temperature": "Temperature", "terminals_overheated": "Terminals overheated", + "total_energy": "Total energy", "total_pv_yield_user": "Total PV yield user", "total_yield": "Total yield", "unknown": "Unknown", @@ -183,6 +187,15 @@ }, "entity": { "binary_sensor": { + "battery_allow_to_charge": { + "name": "Allow to charge" + }, + "battery_allow_to_discharge": { + "name": "Allow to discharge" + }, + "ev_at_site": { + "name": "At site" + }, "evcharger_connected": { "name": "[%key:common::state::connected%]" }, @@ -241,6 +254,9 @@ } }, "device_tracker": { + "ev_gps_location": { + "name": "[%key:common::config_flow::data::location%]" + }, "gps_location": { "name": "[%key:common::config_flow::data::location%]" } @@ -569,6 +585,14 @@ "battery_average_discharge": { "name": "Average discharge" }, + "battery_bms_cable": { + "name": "BMS cable", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, "battery_capacity": { "name": "Capacity" }, @@ -604,6 +628,22 @@ "battery_discharged_energy": { "name": "Discharged energy" }, + "battery_fuse_blown": { + "name": "Fuse blown", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, + "battery_high_cell_voltage": { + "name": "High cell voltage", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, "battery_high_charge_current": { "name": "High charge current", "state": { @@ -628,6 +668,30 @@ "warning": "[%key:component::victron_gx::common::warning%]" } }, + "battery_high_internal_temperature": { + "name": "High internal temperature", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, + "battery_high_temperature": { + "name": "High temperature", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, + "battery_high_voltage": { + "name": "High voltage", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, "battery_installed_capacity": { "name": "Installed capacity" }, @@ -658,6 +722,30 @@ "warning": "[%key:component::victron_gx::common::warning%]" } }, + "battery_low_soc": { + "name": "Low state of charge", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, + "battery_low_temperature": { + "name": "[%key:component::victron_gx::common::low_temperature%]", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, + "battery_low_voltage": { + "name": "Low voltage", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, "battery_max_cell_temperature": { "name": "Maximum cell temperature" }, @@ -728,6 +816,14 @@ "battery_soh": { "name": "State of health" }, + "battery_state_of_health_alarm": { + "name": "State of health alarm", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, "battery_temperature": { "name": "[%key:component::victron_gx::common::temperature%]" }, @@ -926,6 +1022,85 @@ "touch_input_control": "Touch input control" } }, + "ev_ac_current": { + "name": "AC current" + }, + "ev_ac_energy_forward": { + "name": "[%key:component::victron_gx::common::total_energy%]" + }, + "ev_ac_max_charge_current": { + "name": "Max charge current" + }, + "ev_ac_min_charge_current": { + "name": "Min charge current" + }, + "ev_ac_nr_of_phases": { + "name": "Number of phases" + }, + "ev_ac_power": { + "name": "AC power" + }, + "ev_ac_voltage": { + "name": "AC voltage" + }, + "ev_alarm_starter_battery_low": { + "name": "Starter battery low", + "state": { + "alarm": "[%key:component::victron_gx::common::alarm%]", + "no_alarm": "[%key:component::victron_gx::common::no_alarm%]", + "warning": "[%key:component::victron_gx::common::warning%]" + } + }, + "ev_battery_capacity": { + "name": "Battery capacity" + }, + "ev_battery_temperature": { + "name": "Battery temperature" + }, + "ev_charging_started": { + "name": "Charging started" + }, + "ev_charging_state": { + "name": "Charging state", + "state": { + "blocked": "Blocked", + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "low_power_mode": "Low power mode", + "not_charging": "Not charging", + "scheduled_charging": "Scheduled charging", + "sustain": "[%key:component::victron_gx::common::sustain%]", + "unavailable": "Unavailable", + "wake_up": "Wake up" + } + }, + "ev_dc_current": { + "name": "[%key:component::victron_gx::common::dc_current%]" + }, + "ev_dc_power": { + "name": "[%key:component::victron_gx::common::dc_power%]" + }, + "ev_dc_voltage": { + "name": "[%key:component::victron_gx::common::dc_voltage%]" + }, + "ev_last_ev_contact": { + "name": "Last EV contact" + }, + "ev_odometer": { + "name": "Odometer" + }, + "ev_range_to_go": { + "name": "Range to go" + }, + "ev_soc": { + "name": "State of charge" + }, + "ev_target_soc": { + "name": "Target state of charge" + }, + "ev_vin": { + "name": "VIN" + }, "evcharger_current": { "name": "[%key:component::victron_gx::common::current%]" }, @@ -949,8 +1124,7 @@ "name": "[%key:component::victron_gx::common::power_phase%]" }, "evcharger_session_cost": { - "name": "Last session cost", - "unit_of_measurement": "$" + "name": "Last session cost" }, "evcharger_session_energy": { "name": "Last session energy" @@ -988,7 +1162,7 @@ } }, "evcharger_total_energy": { - "name": "Total energy" + "name": "[%key:component::victron_gx::common::total_energy%]" }, "generator_next_test_run": { "name": "Next test run" @@ -1181,13 +1355,13 @@ "name": "AC-in-1 to inverter" }, "multi_acin_current_phase": { - "name": "[%key:component::victron_gx::common::current_phase%]" + "name": "Input current on {phase}" }, "multi_acin_power_phase": { - "name": "[%key:component::victron_gx::common::power_on_phase%]" + "name": "Input power on {phase}" }, "multi_acin_voltage_phase": { - "name": "[%key:component::victron_gx::common::voltage_on_phase%]" + "name": "Input voltage on {phase}" }, "multi_acout_current_phase": { "name": "Output current on {phase}" @@ -1330,7 +1504,7 @@ "name": "Installed version" }, "pvinverter_current_phase": { - "name": "[%key:component::victron_gx::common::current_phase%]" + "name": "Current {phase}" }, "pvinverter_power_phase": { "name": "[%key:component::victron_gx::common::power_phase%]" @@ -1362,7 +1536,7 @@ "active_alarm": "Active alarm", "analysing_input_voltage": "Analysing input voltage", "engine_shutdown": "Engine shutdown on low input voltage", - "low_temperature": "Low temperature", + "low_temperature": "[%key:component::victron_gx::common::low_temperature%]", "need_token": "Need token for operation", "no_battery_power": "No/low battery power", "no_input_power": "No/low input power", @@ -1588,6 +1762,15 @@ "system_dc_pv_power": { "name": "PV power" }, + "system_dvcc_state": { + "name": "DVCC state", + "state": { + "forced_off": "Forced off", + "forced_on": "Forced on", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, "system_dynamicess_available_overhead": { "name": "Dynamic ESS available overhead" }, @@ -1943,16 +2126,16 @@ "name": "[%key:component::victron_gx::common::current_limit%]" }, "vebus_inverter_dc_current": { - "name": "DC current" + "name": "[%key:component::victron_gx::common::dc_current%]" }, "vebus_inverter_dc_power": { - "name": "DC power" + "name": "[%key:component::victron_gx::common::dc_power%]" }, "vebus_inverter_dc_temperature": { "name": "[%key:component::victron_gx::common::dc_temperature%]" }, "vebus_inverter_dc_voltage": { - "name": "DC voltage" + "name": "[%key:component::victron_gx::common::dc_voltage%]" }, "vebus_inverter_ignoreacin1_state": { "name": "State of ignore AC-in-1", @@ -2068,6 +2251,9 @@ "switchable_output_output_state": { "name": "[%key:component::victron_gx::common::state%]" }, + "system_dvcc": { + "name": "DVCC" + }, "system_ess_battery_use": { "name": "ESS only critical loads from battery" }, diff --git a/homeassistant/components/water_heater/condition.py b/homeassistant/components/water_heater/condition.py index a9c8c0d1f336..3d11d13982a7 100644 --- a/homeassistant/components/water_heater/condition.py +++ b/homeassistant/components/water_heater/condition.py @@ -4,12 +4,7 @@ from typing import TYPE_CHECKING, override import voluptuous as vol -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_OPTIONS, - STATE_OFF, - UnitOfTemperature, -) +from homeassistant.const import CONF_OPTIONS, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec @@ -23,7 +18,7 @@ from homeassistant.helpers.condition import ( ) from homeassistant.util.unit_conversion import TemperatureConverter -from .const import DOMAIN +from .const import DOMAIN, WaterHeaterStateAttribute ATTR_OPERATION_MODE = "operation_mode" @@ -73,7 +68,9 @@ class WaterHeaterTargetTemperatureCondition(EntityNumericalConditionWithUnitBase """Condition for water heater target temperature.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=WaterHeaterStateAttribute.TEMPERATURE) + } _unit_converter = TemperatureConverter @override @@ -81,7 +78,7 @@ class WaterHeaterTargetTemperatureCondition(EntityNumericalConditionWithUnitBase """Skip water heater entities that do not expose a target temperature.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_TEMPERATURE) is not None + and state.attributes.get(WaterHeaterStateAttribute.TEMPERATURE) is not None ) @override diff --git a/homeassistant/components/water_heater/reproduce_state.py b/homeassistant/components/water_heater/reproduce_state.py index 7693790f2807..7f22fa399061 100644 --- a/homeassistant/components/water_heater/reproduce_state.py +++ b/homeassistant/components/water_heater/reproduce_state.py @@ -29,6 +29,7 @@ from . import ( STATE_HIGH_DEMAND, STATE_PERFORMANCE, ) +from .const import WaterHeaterStateAttribute _LOGGER = logging.getLogger(__name__) @@ -65,10 +66,10 @@ async def _async_reproduce_state( # Return if we are already at the right state. if ( cur_state.state == state.state - and cur_state.attributes.get(ATTR_TEMPERATURE) - == state.attributes.get(ATTR_TEMPERATURE) - and cur_state.attributes.get(ATTR_AWAY_MODE) - == state.attributes.get(ATTR_AWAY_MODE) + and cur_state.attributes.get(WaterHeaterStateAttribute.TEMPERATURE) + == state.attributes.get(WaterHeaterStateAttribute.TEMPERATURE) + and cur_state.attributes.get(WaterHeaterStateAttribute.AWAY_MODE) + == state.attributes.get(WaterHeaterStateAttribute.AWAY_MODE) ): return @@ -88,31 +89,36 @@ async def _async_reproduce_state( ) if ( - state.attributes.get(ATTR_TEMPERATURE) - != cur_state.attributes.get(ATTR_TEMPERATURE) - and state.attributes.get(ATTR_TEMPERATURE) is not None + state.attributes.get(WaterHeaterStateAttribute.TEMPERATURE) + != cur_state.attributes.get(WaterHeaterStateAttribute.TEMPERATURE) + and state.attributes.get(WaterHeaterStateAttribute.TEMPERATURE) is not None ): await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: state.entity_id, - ATTR_TEMPERATURE: state.attributes.get(ATTR_TEMPERATURE), + ATTR_TEMPERATURE: state.attributes.get( + WaterHeaterStateAttribute.TEMPERATURE + ), }, context=context, blocking=True, ) if ( - state.attributes.get(ATTR_AWAY_MODE) != cur_state.attributes.get(ATTR_AWAY_MODE) - and state.attributes.get(ATTR_AWAY_MODE) is not None + state.attributes.get(WaterHeaterStateAttribute.AWAY_MODE) + != cur_state.attributes.get(WaterHeaterStateAttribute.AWAY_MODE) + and state.attributes.get(WaterHeaterStateAttribute.AWAY_MODE) is not None ): await hass.services.async_call( DOMAIN, SERVICE_SET_AWAY_MODE, { ATTR_ENTITY_ID: state.entity_id, - ATTR_AWAY_MODE: state.attributes.get(ATTR_AWAY_MODE), + ATTR_AWAY_MODE: state.attributes.get( + WaterHeaterStateAttribute.AWAY_MODE + ), }, context=context, blocking=True, diff --git a/homeassistant/components/water_heater/significant_change.py b/homeassistant/components/water_heater/significant_change.py index e741d99f15f2..a322402ddfbd 100644 --- a/homeassistant/components/water_heater/significant_change.py +++ b/homeassistant/components/water_heater/significant_change.py @@ -9,22 +9,21 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from . import ( - ATTR_AWAY_MODE, - ATTR_CURRENT_TEMPERATURE, - ATTR_OPERATION_MODE, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, -) +from .const import WaterHeaterStateAttribute SIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_CURRENT_TEMPERATURE, - ATTR_TEMPERATURE, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - ATTR_OPERATION_MODE, - ATTR_AWAY_MODE, + WaterHeaterStateAttribute.CURRENT_TEMPERATURE, + WaterHeaterStateAttribute.TEMPERATURE, + WaterHeaterStateAttribute.TARGET_TEMP_HIGH, + WaterHeaterStateAttribute.TARGET_TEMP_LOW, + WaterHeaterStateAttribute.OPERATION_MODE, + WaterHeaterStateAttribute.AWAY_MODE, +} + +# Any change to these non-numeric attributes is significant. +NON_NUMERIC_ATTRIBUTES: set[str] = { + WaterHeaterStateAttribute.OPERATION_MODE, + WaterHeaterStateAttribute.AWAY_MODE, } @@ -51,7 +50,7 @@ def async_check_significant_change( ha_unit = hass.config.units.temperature_unit for attr_name in changed_attrs: - if attr_name in [ATTR_OPERATION_MODE, ATTR_AWAY_MODE]: + if attr_name in NON_NUMERIC_ATTRIBUTES: return True old_attr_value = old_attrs.get(attr_name) diff --git a/homeassistant/components/water_heater/trigger.py b/homeassistant/components/water_heater/trigger.py index 5d81f9479a11..8d692e396e60 100644 --- a/homeassistant/components/water_heater/trigger.py +++ b/homeassistant/components/water_heater/trigger.py @@ -4,12 +4,7 @@ from typing import override import voluptuous as vol -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_OPTIONS, - STATE_OFF, - UnitOfTemperature, -) +from homeassistant.const import CONF_OPTIONS, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec @@ -26,7 +21,7 @@ from homeassistant.helpers.trigger import ( ) from homeassistant.util.unit_conversion import TemperatureConverter -from .const import DOMAIN +from .const import DOMAIN, WaterHeaterStateAttribute CONF_OPERATION_MODE = "operation_mode" @@ -61,7 +56,9 @@ class _WaterHeaterTargetTemperatureTriggerMixin( """Mixin for water heater target temperature triggers with unit conversion.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=WaterHeaterStateAttribute.TEMPERATURE) + } _unit_converter = TemperatureConverter @override @@ -69,7 +66,7 @@ class _WaterHeaterTargetTemperatureTriggerMixin( """Skip water heater entities that do not expose a target temperature.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_TEMPERATURE) is not None + and state.attributes.get(WaterHeaterStateAttribute.TEMPERATURE) is not None ) @override diff --git a/homeassistant/components/wattwaechter/diagnostics.py b/homeassistant/components/wattwaechter/diagnostics.py new file mode 100644 index 000000000000..a59cfaa9df29 --- /dev/null +++ b/homeassistant/components/wattwaechter/diagnostics.py @@ -0,0 +1,53 @@ +"""Diagnostics support for the WattWächter Plus integration.""" + +from dataclasses import asdict +from typing import Any + +from aio_wattwaechter import ( + WattwaechterAuthenticationError, + WattwaechterConnectionError, +) +from aio_wattwaechter.models import SystemInfo + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_MAC, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import WattwaechterConfigEntry + +# The device exposes network identifiers as system info values; redact the +# credential and hardware/network identifiers. Local IPs are kept for support. +TO_REDACT = {CONF_TOKEN, CONF_MAC, "ssid", "mac_address", "mdns_name"} + + +def _flatten_system(system: SystemInfo) -> dict[str, dict[str, Any]]: + """Flatten system info sections into {section: {name: value}} mappings.""" + return { + section: {entry["name"]: entry["value"] for entry in entries} + for section, entries in asdict(system).items() + } + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: WattwaechterConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + # System info is only needed on demand here, so it is fetched directly + # instead of in the update loop to avoid coupling meter sensor + # availability to it. Failure still yields the config and meter data. + system: dict[str, dict[str, Any]] | None = None + try: + system = _flatten_system(await coordinator.client.system_info()) + except WattwaechterConnectionError, WattwaechterAuthenticationError: + system = None + + return async_redact_data( + { + "config_entry": dict(entry.data), + "meter": asdict(coordinator.data), + "system": system, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/wattwaechter/quality_scale.yaml b/homeassistant/components/wattwaechter/quality_scale.yaml index 247726f5ade8..a831a42304cf 100644 --- a/homeassistant/components/wattwaechter/quality_scale.yaml +++ b/homeassistant/components/wattwaechter/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: todo diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py index 50da77ef6474..70d837b1dd2d 100644 --- a/homeassistant/components/weather/significant_change.py +++ b/homeassistant/components/weather/significant_change.py @@ -9,37 +9,21 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from .const import ( - ATTR_WEATHER_APPARENT_TEMPERATURE, - ATTR_WEATHER_CLOUD_COVERAGE, - ATTR_WEATHER_DEW_POINT, - ATTR_WEATHER_HUMIDITY, - ATTR_WEATHER_OZONE, - ATTR_WEATHER_PRESSURE, - ATTR_WEATHER_PRESSURE_UNIT, - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_TEMPERATURE_UNIT, - ATTR_WEATHER_UV_INDEX, - ATTR_WEATHER_VISIBILITY, - ATTR_WEATHER_WIND_BEARING, - ATTR_WEATHER_WIND_GUST_SPEED, - ATTR_WEATHER_WIND_SPEED, - ATTR_WEATHER_WIND_SPEED_UNIT, -) +from .const import WeatherEntityStateAttribute SIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_WEATHER_APPARENT_TEMPERATURE, - ATTR_WEATHER_CLOUD_COVERAGE, - ATTR_WEATHER_DEW_POINT, - ATTR_WEATHER_HUMIDITY, - ATTR_WEATHER_OZONE, - ATTR_WEATHER_PRESSURE, - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_UV_INDEX, - ATTR_WEATHER_VISIBILITY, - ATTR_WEATHER_WIND_BEARING, - ATTR_WEATHER_WIND_GUST_SPEED, - ATTR_WEATHER_WIND_SPEED, + WeatherEntityStateAttribute.APPARENT_TEMPERATURE, + WeatherEntityStateAttribute.CLOUD_COVERAGE, + WeatherEntityStateAttribute.DEW_POINT, + WeatherEntityStateAttribute.HUMIDITY, + WeatherEntityStateAttribute.OZONE, + WeatherEntityStateAttribute.PRESSURE, + WeatherEntityStateAttribute.TEMPERATURE, + WeatherEntityStateAttribute.UV_INDEX, + WeatherEntityStateAttribute.VISIBILITY, + WeatherEntityStateAttribute.WIND_BEARING, + WeatherEntityStateAttribute.WIND_GUST_SPEED, + WeatherEntityStateAttribute.WIND_SPEED, } VALID_CARDINAL_DIRECTIONS: list[str] = [ @@ -99,7 +83,7 @@ def async_check_significant_change( old_attr_value = old_attrs.get(attr_name) new_attr_value = new_attrs.get(attr_name) absolute_change: float | None = None - if attr_name == ATTR_WEATHER_WIND_BEARING: + if attr_name == WeatherEntityStateAttribute.WIND_BEARING: old_attr_value = _cardinal_to_degrees(old_attr_value) new_attr_value = _cardinal_to_degrees(new_attr_value) @@ -112,23 +96,23 @@ def async_check_significant_change( return True if attr_name in ( - ATTR_WEATHER_APPARENT_TEMPERATURE, - ATTR_WEATHER_DEW_POINT, - ATTR_WEATHER_TEMPERATURE, + WeatherEntityStateAttribute.APPARENT_TEMPERATURE, + WeatherEntityStateAttribute.DEW_POINT, + WeatherEntityStateAttribute.TEMPERATURE, ): if ( - unit := new_attrs.get(ATTR_WEATHER_TEMPERATURE_UNIT) + unit := new_attrs.get(WeatherEntityStateAttribute.TEMPERATURE_UNIT) ) is not None and unit == UnitOfTemperature.FAHRENHEIT: absolute_change = 1.0 else: absolute_change = 0.5 if attr_name in ( - ATTR_WEATHER_WIND_GUST_SPEED, - ATTR_WEATHER_WIND_SPEED, + WeatherEntityStateAttribute.WIND_GUST_SPEED, + WeatherEntityStateAttribute.WIND_SPEED, ): if ( - unit := new_attrs.get(ATTR_WEATHER_WIND_SPEED_UNIT) + unit := new_attrs.get(WeatherEntityStateAttribute.WIND_SPEED_UNIT) ) is None or unit in ( UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR, # 1km/h = 0.62mi/s @@ -139,19 +123,23 @@ def async_check_significant_change( absolute_change = 0.5 if attr_name in ( - ATTR_WEATHER_CLOUD_COVERAGE, # range 0-100% - ATTR_WEATHER_HUMIDITY, # range 0-100% - ATTR_WEATHER_OZONE, # range ~20-100ppm - ATTR_WEATHER_VISIBILITY, # range 0-240km (150mi) - ATTR_WEATHER_WIND_BEARING, # range 0-359° + WeatherEntityStateAttribute.CLOUD_COVERAGE, # range 0-100% + WeatherEntityStateAttribute.HUMIDITY, # range 0-100% + WeatherEntityStateAttribute.OZONE, # range ~20-100ppm + WeatherEntityStateAttribute.VISIBILITY, # range 0-240km (150mi) + WeatherEntityStateAttribute.WIND_BEARING, # range 0-359° ): absolute_change = 1.0 - if attr_name == ATTR_WEATHER_UV_INDEX: # range 1-11 + if attr_name == WeatherEntityStateAttribute.UV_INDEX: # range 1-11 absolute_change = 0.1 - if attr_name == ATTR_WEATHER_PRESSURE: # local variation of around 100 hpa - if (unit := new_attrs.get(ATTR_WEATHER_PRESSURE_UNIT)) is None or unit in ( + if ( + attr_name == WeatherEntityStateAttribute.PRESSURE + ): # local variation of around 100 hpa + if ( + unit := new_attrs.get(WeatherEntityStateAttribute.PRESSURE_UNIT) + ) is None or unit in ( UnitOfPressure.HPA, UnitOfPressure.MBAR, # 1hPa = 1mbar UnitOfPressure.MMHG, # 1hPa = 0.75mmHg diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 789334a9d9fd..23026bbda1bd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -424,6 +424,7 @@ FLOWS = { "litejet", "litterrobot", "livisi", + "llama_cpp", "local_calendar", "local_file", "local_ip", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 58949fe0594e..400d0ac4fb54 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3887,6 +3887,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "llama_cpp": { + "name": "llama.cpp", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "llamalab_automate": { "name": "LlamaLab Automate", "integration_type": "hub", diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index f93009cbeb39..a380cec1ae31 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -27,6 +27,10 @@ from .json import find_paths_unserializable_data, json_bytes, json_dumps _LOGGER = logging.getLogger(__name__) +# Responses smaller than this fit within a single network packet, so +# compressing them wastes event-loop CPU without reducing round-trips. +MIN_COMPRESSED_RESPONSE_SIZE: Final = 1024 + type AllowCorsType = Callable[[AbstractRoute | AbstractResource], None] KEY_AUTHENTICATED: Final = "ha_authenticated" @@ -160,7 +164,8 @@ class HomeAssistantView: headers=headers, zlib_executor_size=32768, ) - response.enable_compression() + if len(msg) > MIN_COMPRESSED_RESPONSE_SIZE: + response.enable_compression() return response def json_message( diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index f89608f793f7..64ea03a869f1 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -191,7 +191,7 @@ class LLMContext: language: str | None """Language of the LLM request.""" - assistant: str | None + assistant: str """Assistant domain that is handling the LLM request.""" device_id: str | None @@ -1278,11 +1278,6 @@ class GetLiveContextTool(Tool): llm_context: LLMContext, ) -> JsonObjectType: """Get the current state of exposed entities.""" - if llm_context.assistant is None: - # Note this doesn't happen in practice since this tool won't be - # exposed if no assistant is configured. - return {"success": False, "error": "No assistant configured"} - args = self.parameters(tool_input.tool_args) exposed_entities = _get_exposed_entities(hass, llm_context.assistant) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 13cfb465cc3a..37e9c989b35b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.26.2 hass-nabucasa==2.2.0 hassil==3.8.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260624.3 +home-assistant-frontend==20260624.4 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/mypy.ini b/mypy.ini index 519bd1cb4c6b..9791660c9a21 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3227,6 +3227,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.llama_cpp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.local_ip.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 42684e4272f4..f9e4b3d4ebec 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest from typing import Final -FRONTEND_VERSION: Final[str] = "20260624.3" +FRONTEND_VERSION: Final[str] = "20260624.4" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/requirements_all.txt b/requirements_all.txt index 79467edf318f..828728189e44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -193,7 +193,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==14.1.8 +aioamazondevices==14.1.9 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -303,7 +303,7 @@ aiohue==4.8.1 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.15.1 +aioimmich==0.16.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -976,7 +976,7 @@ eurotronic-cometblue-ha==1.4.0 # evdev==1.9.3 # homeassistant.components.evohome -evohome-async==1.2.0 +evohome-async==2.0.1 # homeassistant.components.bryant_evolution evolutionhttp==0.0.19 @@ -1266,7 +1266,7 @@ hole==0.9.2 holidays==0.99 # homeassistant.components.frontend -home-assistant-frontend==20260624.3 +home-assistant-frontend==20260624.4 # homeassistant.components.conversation home-assistant-intents==2026.6.24 @@ -1752,6 +1752,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.cloud +# homeassistant.components.llama_cpp # homeassistant.components.open_router # homeassistant.components.openai_conversation # homeassistant.components.ovhcloud_ai_endpoints @@ -2301,7 +2302,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.2.5 +pylamarzocco==2.4.2 # homeassistant.components.lastfm pylast==5.1.0 @@ -2537,7 +2538,7 @@ pyseventeentrack==1.1.3 pysiaalarm==3.2.2 # homeassistant.components.signal_messenger -pysignalclirestapi==0.3.24 +pysignalclirestapi==0.3.25 # homeassistant.components.sky_hub pyskyqhub==0.1.4 @@ -2785,7 +2786,7 @@ pytradfri[async]==9.0.1 pytrafikverket==1.1.1 # homeassistant.components.v2c -pytrydan==1.0.2 +pytrydan==1.0.3 # homeassistant.components.uptimerobot pyuptimerobot==25.0.0 @@ -3143,7 +3144,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.4.7 +tesla-fleet-api==1.5.2 # homeassistant.components.powerwall tesla-powerwall==0.5.3 @@ -3289,7 +3290,7 @@ viaggiatreno_ha==0.2.4 victron-ble-ha-parser==0.7.0 # homeassistant.components.victron_gx -victron-mqtt==2026.6.1.1 +victron-mqtt==2026.6.6 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.12 diff --git a/requirements_test.txt b/requirements_test.txt index bd0ad2d1b002..7378f18e9778 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -38,7 +38,7 @@ pytest==9.0.3 requests==2.34.2 requests-mock==1.12.1 respx==0.23.1 -syrupy==5.3.2 +syrupy==5.3.4 tqdm==4.67.1 types-aiofiles==24.1.0.20250822 types-atomicwrites==1.4.5.1 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index db650a140fca..4a46a5ca06a3 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2056,6 +2056,7 @@ NO_QUALITY_SCALE = [ "intent_script", "intent", "labs", + "llm", "logbook", "logger", "lovelace", diff --git a/tests/components/airobot/test_button.py b/tests/components/airobot/test_button.py index 529605836a99..59b50b05d31e 100644 --- a/tests/components/airobot/test_button.py +++ b/tests/components/airobot/test_button.py @@ -112,12 +112,21 @@ async def test_recalibrate_co2_button( @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +@pytest.mark.parametrize( + "exception", + [ + AirobotError("Test error"), + AirobotConnectionError("Connection lost"), + AirobotTimeoutError("Timeout"), + ], +) async def test_recalibrate_co2_button_error( hass: HomeAssistant, mock_airobot_client: AsyncMock, + exception: Exception, ) -> None: """Test recalibrate CO2 sensor button error handling.""" - mock_airobot_client.recalibrate_co2_sensor.side_effect = AirobotError("Test error") + mock_airobot_client.recalibrate_co2_sensor.side_effect = exception with pytest.raises(HomeAssistantError): await hass.services.async_call( diff --git a/tests/components/airos/test_init.py b/tests/components/airos/test_init.py index 2191d9a39a85..3930da83afe6 100644 --- a/tests/components/airos/test_init.py +++ b/tests/components/airos/test_init.py @@ -55,6 +55,26 @@ MOCK_CONFIG_PLAIN = { } MOCK_CONFIG_V1_2 = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", + "advanced_settings": { + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + }, +} + +MOCK_CONFIG_V2_1 = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", + "advanced_settings": { + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + }, +} + +MOCK_CONFIG_V3_1 = { CONF_HOST: "1.1.1.1", CONF_USERNAME: "ubnt", CONF_PASSWORD: "test-password", @@ -104,7 +124,7 @@ async def test_setup_entry_without_ssl( data=MOCK_CONFIG_PLAIN, entry_id="1", unique_id="airos_device", - version=1, + version=2, minor_version=2, ) entry.add_to_hass(hass) @@ -146,9 +166,9 @@ async def test_ssl_migrate_entry( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.version == 2 + assert entry.version == 3 assert entry.minor_version == 1 - assert entry.data == MOCK_CONFIG_V1_2 + assert entry.data == MOCK_CONFIG_V3_1 @pytest.mark.parametrize( @@ -209,7 +229,7 @@ async def test_uid_migrate_entry( updated_entity_entry = entity_registry.async_get(original_entity_id) assert entry.state is ConfigEntryState.LOADED - assert entry.version == 2 + assert entry.version == 3 assert entry.minor_version == 1 assert ( entity_registry.async_get_entity_id(sensor_domain, DOMAIN, old_unique_id) @@ -218,6 +238,32 @@ async def test_uid_migrate_entry( assert updated_entity_entry.unique_id == new_unique_id +async def test_migrate_additional_settings( + hass: HomeAssistant, + mock_airos_client: MagicMock, + mock_async_get_firmware_data: AsyncMock, +) -> None: + """Test rename advanced_settings.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=MOCK_CONFIG_V2_1, + entry_id="1", + unique_id="airos_device", + version=2, + minor_version=1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 3 + assert entry.minor_version == 1 + assert entry.data == MOCK_CONFIG_V3_1 + + async def test_migrate_future_return( hass: HomeAssistant, mock_airos_client: MagicMock, @@ -230,7 +276,7 @@ async def test_migrate_future_return( data=MOCK_CONFIG_V1_2, entry_id="1", unique_id="airos_device", - version=3, + version=4, ) entry.add_to_hass(hass) @@ -308,7 +354,7 @@ async def test_setup_entry_with_legacy_ssl( domain=DOMAIN, title="NanoStation", unique_id="01:23:45:67:89:AB", - data={**MOCK_CONFIG_V1_2, CONF_LEGACY_SSL: True}, + data={**MOCK_CONFIG_V3_1, CONF_LEGACY_SSL: True}, ) legacy_entry.add_to_hass(hass) @@ -338,9 +384,9 @@ async def test_setup_entry_with_legacy_ssl( mock_build_legacy_context.assert_called_once_with(verify_ssl=DEFAULT_VERIFY_SSL) mock_airos_class.assert_called_once_with( - host=MOCK_CONFIG_V1_2[CONF_HOST], - username=MOCK_CONFIG_V1_2[CONF_USERNAME], - password=MOCK_CONFIG_V1_2[CONF_PASSWORD], + host=MOCK_CONFIG_V3_1[CONF_HOST], + username=MOCK_CONFIG_V3_1[CONF_USERNAME], + password=MOCK_CONFIG_V3_1[CONF_PASSWORD], session=legacy_session, use_ssl=DEFAULT_SSL, ) @@ -370,7 +416,7 @@ async def test_setup_entry_with_legacy_ssl_fails_firmware_detect( domain=DOMAIN, title="NanoStation", unique_id="01:23:45:67:89:AB", - data={**MOCK_CONFIG_V1_2, CONF_LEGACY_SSL: True}, + data={**MOCK_CONFIG_V3_1, CONF_LEGACY_SSL: True}, ) legacy_entry.add_to_hass(hass) diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index df95342e9bcf..7db0e2fc5679 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -51,6 +51,33 @@ async def test_api_list_state_entities( assert remote_data == local_data +@pytest.mark.parametrize( + ("entity_count", "expect_compression"), + [ + pytest.param(1, False, id="small-body-not-compressed"), + pytest.param(50, True, id="large-body-compressed"), + ], +) +async def test_api_states_compression_threshold( + hass: HomeAssistant, + mock_api_client: TestClient, + entity_count: int, + expect_compression: bool, +) -> None: + """Test that only state list responses above the size threshold are compressed.""" + for i in range(entity_count): + hass.states.async_set( + f"test.entity_{i}", "on", {"friendly_name": f"Entity {i}"} + ) + + resp = await mock_api_client.get( + const.URL_API_STATES, headers={"Accept-Encoding": "gzip, deflate"} + ) + + assert resp.status == HTTPStatus.OK + assert ("Content-Encoding" in resp.headers) is expect_compression + + async def test_api_get_state(hass: HomeAssistant, mock_api_client: TestClient) -> None: """Test if the debug interface allows us to get a state.""" hass.states.async_set("hello.world", "nice", {"attr": 1}) diff --git a/tests/components/aurora/__init__.py b/tests/components/aurora/__init__.py index eca5281f6312..5be18fffb385 100644 --- a/tests/components/aurora/__init__.py +++ b/tests/components/aurora/__init__.py @@ -6,7 +6,7 @@ from tests.common import MockConfigEntry async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Fixture for setting up the component.""" + """Fixture for setting up the integration.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/blebox/test_climate.py b/tests/components/blebox/test_climate.py index 6aa7c02ebcbe..7d55d8447b09 100644 --- a/tests/components/blebox/test_climate.py +++ b/tests/components/blebox/test_climate.py @@ -135,128 +135,58 @@ async def test_update(saunabox, hass: HomeAssistant, config) -> None: assert state.state == HVACMode.OFF -async def test_on_when_below_desired(saunabox, hass: HomeAssistant) -> None: - """Test when temperature is below desired.""" +async def test_set_hvac_mode_heat(saunabox, hass: HomeAssistant) -> None: + """Test that setting HVAC mode to heat calls async_on.""" feature_mock, entity_id = saunabox - feature_mock.is_on = False - await async_setup_entity(hass, entity_id) - - def turn_on(): - feature_mock.is_on = True - feature_mock.is_heating = True - feature_mock.desired = 64.8 - feature_mock.current = 25.7 - feature_mock.mode = 1 - feature_mock.async_on = AsyncMock(side_effect=turn_on) - await hass.services.async_call( - "climate", - SERVICE_SET_HVAC_MODE, - {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, - blocking=True, - ) - feature_mock.async_off.assert_not_called() - state = hass.states.get(entity_id) - - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_TEMPERATURE] == 64.8 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.7 - assert state.state == HVACMode.HEAT - - -async def test_on_when_above_desired(saunabox, hass: HomeAssistant) -> None: - """Test when temperature is below desired.""" - - feature_mock, entity_id = saunabox - - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - def turn_on(): - feature_mock.is_on = True - feature_mock.is_heating = False - feature_mock.desired = 23.4 - feature_mock.current = 28.7 - - feature_mock.mode = 1 - feature_mock.async_on = AsyncMock(side_effect=turn_on) - await hass.services.async_call( "climate", SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) + + feature_mock.async_on.assert_called_once_with() feature_mock.async_off.assert_not_called() - state = hass.states.get(entity_id) - - assert state.attributes[ATTR_TEMPERATURE] == 23.4 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 28.7 - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.state == HVACMode.HEAT -async def test_off(saunabox, hass: HomeAssistant) -> None: - """Test turning off.""" +async def test_set_hvac_mode_off(saunabox, hass: HomeAssistant) -> None: + """Test that setting HVAC mode to off calls async_off.""" feature_mock, entity_id = saunabox - feature_mock.is_on = True - feature_mock.is_heating = False await async_setup_entity(hass, entity_id) - def turn_off(): - feature_mock.is_on = False - feature_mock.is_heating = False - feature_mock.desired = 29.8 - feature_mock.current = 22.7 - - feature_mock.async_off = AsyncMock(side_effect=turn_off) await hass.services.async_call( "climate", SERVICE_SET_HVAC_MODE, {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - feature_mock.async_on.assert_not_called() - state = hass.states.get(entity_id) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF - assert state.attributes[ATTR_TEMPERATURE] == 29.8 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.7 - assert state.state == HVACMode.OFF + feature_mock.async_off.assert_called_once_with() + feature_mock.async_on.assert_not_called() async def test_set_thermo(saunabox, hass: HomeAssistant) -> None: - """Test setting thermostat.""" + """Test that setting the temperature calls async_set_temperature.""" feature_mock, entity_id = saunabox - feature_mock.is_on = False - feature_mock.is_heating = False await async_setup_entity(hass, entity_id) - def set_temp(temp): - feature_mock.is_on = True - feature_mock.is_heating = True - feature_mock.desired = 29.2 - feature_mock.current = 29.1 - - feature_mock.async_set_temperature = AsyncMock(side_effect=set_temp) await hass.services.async_call( "climate", SERVICE_SET_TEMPERATURE, {"entity_id": entity_id, ATTR_TEMPERATURE: 43.21}, blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_TEMPERATURE] == 29.2 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 29.1 - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.state == HVACMode.HEAT + feature_mock.async_set_temperature.assert_called_once_with(43.21) async def test_update_failure( @@ -280,55 +210,30 @@ async def test_update_failure( assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_reding_hvac_actions( - saunabox, hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test hvac action for given device(mock) state.""" - - caplog.set_level(logging.ERROR) +async def test_hvac_action_heating(saunabox, hass: HomeAssistant) -> None: + """Test hvac_action reflects a heating device state.""" feature_mock, entity_id = saunabox + + feature_mock.is_on = True + feature_mock.hvac_action = 1 + feature_mock.mode = 1 await async_setup_entity(hass, entity_id) - def set_temperature(temp): - feature_mock.is_on = True - feature_mock.hvac_action = 1 - feature_mock.mode = 1 - - feature_mock.async_set_temperature = AsyncMock(side_effect=set_temperature) - - await hass.services.async_call( - "climate", - SERVICE_SET_TEMPERATURE, - {"entity_id": entity_id, ATTR_TEMPERATURE: 43.21}, - blocking=True, - ) state = hass.states.get(entity_id) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF, HVACMode.HEAT] -async def test_thermo_off( - thermobox, hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test hvac action off fir given device state.""" - caplog.set_level(logging.ERROR) +async def test_hvac_action_off(thermobox, hass: HomeAssistant) -> None: + """Test hvac_action reflects a device that is off.""" feature_mock, entity_id = thermobox + + feature_mock.is_on = False + feature_mock.hvac_action = 0 await async_setup_entity(hass, entity_id) - def set_off(): - feature_mock.is_on = False - feature_mock.hvac_action = 0 - - feature_mock.async_off = AsyncMock(side_effect=set_off) - - await hass.services.async_call( - "climate", - SERVICE_SET_HVAC_MODE, - {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, - blocking=True, - ) state = hass.states.get(entity_id) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF, HVACMode.COOL] diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index 56a3bec3e293..b057e178d56d 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -259,21 +259,16 @@ async def test_open(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 3 # manually stopped await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.CLOSED - def open_gate(): - feature_mock.state = 1 # opening - - feature_mock.async_open = AsyncMock(side_effect=open_gate) await hass.services.async_call( "cover", SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.OPENING + + feature_mock.async_open.assert_called_once_with() @pytest.mark.parametrize("feature", ALL_COVER_FIXTURES, indirect=["feature"]) @@ -282,18 +277,13 @@ async def test_close(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 4 # open await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.OPEN - def close(): - feature_mock.state = 0 # closing - - feature_mock.async_close = AsyncMock(side_effect=close) await hass.services.async_call( "cover", SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True ) - assert hass.states.get(entity_id).state == CoverState.CLOSING + + feature_mock.async_close.assert_called_once_with() @pytest.mark.parametrize("feature", FIXTURES_SUPPORTING_STOP, indirect=["feature"]) @@ -302,18 +292,13 @@ async def test_stop(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 1 # opening await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.OPENING - def stop(): - feature_mock.state = 2 # manually stopped - - feature_mock.async_stop = AsyncMock(side_effect=stop) await hass.services.async_call( "cover", SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True ) - assert hass.states.get(entity_id).state == CoverState.OPEN + + feature_mock.async_stop.assert_called_once_with() @pytest.mark.parametrize( @@ -355,23 +340,16 @@ async def test_set_position(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 3 # closed await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.CLOSED - def set_position(position): - assert position == 99 # inverted - feature_mock.state = 1 # opening - # feature_mock.current = position - - feature_mock.async_set_position = AsyncMock(side_effect=set_position) await hass.services.async_call( "cover", SERVICE_SET_COVER_POSITION, {"entity_id": entity_id, ATTR_POSITION: 1}, blocking=True, ) # almost closed - assert hass.states.get(entity_id).state == CoverState.OPENING + + feature_mock.async_set_position.assert_called_once_with(99) # inverted async def test_unknown_position(shutterbox, hass: HomeAssistant) -> None: @@ -540,29 +518,23 @@ async def test_set_tilt_position(shutterbox, hass: HomeAssistant) -> None: feature_mock, entity_id = shutterbox - feature_mock.state = 3 await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.CLOSED - def set_tilt(tilt_position): - assert tilt_position == 20 - feature_mock.state = 1 - - feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt) await hass.services.async_call( "cover", SERVICE_SET_COVER_TILT_POSITION, {"entity_id": entity_id, ATTR_TILT_POSITION: 80}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.OPENING + + feature_mock.async_set_tilt_position.assert_called_once_with(20) @pytest.mark.parametrize( - ("is_tilt_180", "expected_tilt_position", "expected_tilt_reported"), + ("is_tilt_180", "expected_tilt_position"), [ - pytest.param(False, 0, 100, id="tilt_90"), - pytest.param(True, 50, 50, id="tilt_180"), + pytest.param(False, 0, id="tilt_90"), + pytest.param(True, 50, id="tilt_180"), ], ) async def test_open_tilt( @@ -570,7 +542,6 @@ async def test_open_tilt( hass: HomeAssistant, is_tilt_180: bool, expected_tilt_position: int, - expected_tilt_reported: int, ) -> None: """Test opening tilt for 90-degree and 180-degree tilt shutters.""" feature_mock, entity_id = shutterbox @@ -578,42 +549,27 @@ async def test_open_tilt( feature_mock.tilt_current = 100 await async_setup_entity(hass, entity_id) - def set_tilt_position(tilt_position): - assert tilt_position == expected_tilt_position - feature_mock.tilt_current = tilt_position - - feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt_position) - await hass.services.async_call( "cover", SERVICE_OPEN_COVER_TILT, {"entity_id": entity_id}, blocking=True, ) - state = hass.states.get(entity_id) - assert ( - state.attributes[ATTR_CURRENT_TILT_POSITION] == expected_tilt_reported - ) # inverted + + feature_mock.async_set_tilt_position.assert_called_once_with(expected_tilt_position) async def test_close_tilt(shutterbox, hass: HomeAssistant) -> None: """Test closing tilt.""" feature_mock, entity_id = shutterbox - feature_mock.tilt_current = 0 await async_setup_entity(hass, entity_id) - def set_tilt_position(tilt_position): - assert tilt_position == 100 - feature_mock.tilt_current = tilt_position - - feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt_position) - await hass.services.async_call( "cover", SERVICE_CLOSE_COVER_TILT, {"entity_id": entity_id}, blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 # inverted + + feature_mock.async_set_tilt_position.assert_called_once_with(100) diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index 8d0bdd74291a..031f4bca861a 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock import blebox_uniapi import pytest -from homeassistant.components.blebox.const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -19,7 +18,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, STATE_ON, STATE_UNKNOWN, ) @@ -103,20 +101,9 @@ async def test_dimmer_on(dimmer, hass: HomeAssistant) -> None: feature_mock, entity_id = dimmer - feature_mock.is_on = False - feature_mock.brightness = 0 # off feature_mock.sensible_on_value = 254 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(brightness): - assert brightness == 254 - feature_mock.brightness = 254 # on - feature_mock.is_on = True # on - - feature_mock.async_on = AsyncMock(side_effect=turn_on) await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -124,9 +111,7 @@ async def test_dimmer_on(dimmer, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert state.attributes[ATTR_BRIGHTNESS] == 254 + feature_mock.async_on.assert_called_once_with(254) async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None: @@ -134,21 +119,9 @@ async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None: feature_mock, entity_id = dimmer - feature_mock.is_on = False - feature_mock.brightness = 0 # off feature_mock.sensible_on_value = 254 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(brightness): - assert brightness == 202 - feature_mock.brightness = 202 # on - feature_mock.is_on = True # on - - feature_mock.async_on = AsyncMock(side_effect=turn_on) - def apply(value, brightness): assert value == 254 return brightness @@ -161,9 +134,7 @@ async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_BRIGHTNESS] == 202 - assert state.state == STATE_ON + feature_mock.async_on.assert_called_once_with(202) async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: @@ -171,17 +142,8 @@ async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: feature_mock, entity_id = dimmer - feature_mock.is_on = True await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - def turn_off(): - feature_mock.is_on = False - feature_mock.brightness = 0 # off - - feature_mock.async_off = AsyncMock(side_effect=turn_off) await hass.services.async_call( "light", SERVICE_TURN_OFF, @@ -189,9 +151,7 @@ async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - assert state.attributes[ATTR_BRIGHTNESS] is None + feature_mock.async_off.assert_called_once_with() @pytest.fixture(name="wlightbox_s") @@ -264,19 +224,9 @@ async def test_wlightbox_s_on(wlightbox_s, hass: HomeAssistant) -> None: feature_mock, entity_id = wlightbox_s - feature_mock.is_on = False feature_mock.sensible_on_value = 254 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(brightness): - assert brightness == 254 - feature_mock.brightness = 254 # on - feature_mock.is_on = True # on - - feature_mock.async_on = AsyncMock(side_effect=turn_on) await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -284,9 +234,7 @@ async def test_wlightbox_s_on(wlightbox_s, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_BRIGHTNESS] == 254 - assert state.state == STATE_ON + feature_mock.async_on.assert_called_once_with(254) @pytest.fixture(name="wlightbox") @@ -360,12 +308,7 @@ async def test_wlightbox_on_color_temp( transient_temp = value return [0x00, 0x39, 0xB0, 0xFF] - def turn_on(_: list[int]) -> None: - feature_mock.is_on = True - feature_mock.color_temp = transient_temp - feature_mock.return_color_temp_with_brightness = return_color_temp_with_brightness - feature_mock.async_on = AsyncMock(side_effect=turn_on) await async_setup_entity(hass, entity_id) await hass.services.async_call( @@ -375,12 +318,8 @@ async def test_wlightbox_on_color_temp( blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON assert 0 <= transient_temp <= 255 - - kelvin_actual = state.attributes[ATTR_COLOR_TEMP_KELVIN] - assert LIGHT_MIN_KELVINS <= kelvin_actual <= LIGHT_MAX_KELVINS + feature_mock.async_on.assert_called_once_with([0x00, 0x39, 0xB0, 0xFF]) async def test_wlightbox_init( @@ -431,35 +370,8 @@ async def test_wlightbox_on_rgbw(wlightbox, hass: HomeAssistant) -> None: feature_mock, entity_id = wlightbox - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(value): - feature_mock.is_on = True - assert value == [193, 210, 243, 199] - feature_mock.white_value = 0xC7 # on - feature_mock.rgbw_hex = "c1d2f3c7" - - feature_mock.async_on = AsyncMock(side_effect=turn_on) - - def apply_white(value, white): - assert value == "00010203" - assert white == 0xC7 - return "000102c7" - - feature_mock.apply_white = apply_white - - def apply_color(value, color_value): - assert value == "000102c7" - assert color_value == "c1d2f3" - return "c1d2f3c7" - - feature_mock.apply_color = apply_color - feature_mock.sensible_on_value = "00010203" - await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -467,9 +379,7 @@ async def test_wlightbox_on_rgbw(wlightbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert state.attributes[ATTR_RGBW_COLOR] == (0xC1, 0xD2, 0xF3, 0xC7) + feature_mock.async_on.assert_called_once_with([193, 210, 243, 199]) async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> None: @@ -477,20 +387,8 @@ async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> Non feature_mock, entity_id = wlightbox - feature_mock.is_on = False - await async_setup_entity(hass, entity_id) - - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(value): - feature_mock.is_on = True - assert value == "f1e2d3e4" - feature_mock.white_value = 0xE4 - feature_mock.rgbw_hex = value - - feature_mock.async_on = AsyncMock(side_effect=turn_on) feature_mock.sensible_on_value = "f1e2d3e4" + await async_setup_entity(hass, entity_id) await hass.services.async_call( "light", @@ -499,9 +397,7 @@ async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> Non blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_RGBW_COLOR] == (0xF1, 0xE2, 0xD3, 0xE4) - assert state.state == STATE_ON + feature_mock.async_on.assert_called_once_with("f1e2d3e4") async def test_wlightbox_turn_on_with_zero_brightness_turns_off( @@ -511,23 +407,8 @@ async def test_wlightbox_turn_on_with_zero_brightness_turns_off( feature_mock, entity_id = wlightbox - feature_mock.is_on = True - feature_mock.rgbw_hex = "c1d2f3c7" - feature_mock.white_value = 0xC7 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - feature_mock.apply_brightness = MagicMock(return_value=[0, 0, 0, 0]) - - def turn_off(): - feature_mock.is_on = False - feature_mock.white_value = 0x0 - feature_mock.rgbw_hex = "00000000" - - feature_mock.async_off = AsyncMock(side_effect=turn_off) - await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -535,31 +416,17 @@ async def test_wlightbox_turn_on_with_zero_brightness_turns_off( blocking=True, ) - feature_mock.async_off.assert_called_once() + feature_mock.async_off.assert_called_once_with() feature_mock.async_on.assert_not_called() - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None: """Test light off.""" feature_mock, entity_id = wlightbox - feature_mock.is_on = True await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - def turn_off(): - feature_mock.is_on = False - feature_mock.white_value = 0x0 - feature_mock.rgbw_hex = "00000000" - - feature_mock.async_off = AsyncMock(side_effect=turn_off) - await hass.services.async_call( "light", SERVICE_TURN_OFF, @@ -567,9 +434,7 @@ async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_RGBW_COLOR] is None - assert state.state == STATE_OFF + feature_mock.async_off.assert_called_once_with() @pytest.mark.parametrize("feature", ALL_LIGHT_FIXTURES, indirect=["feature"]) @@ -623,18 +488,8 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None: feature_mock, entity_id = wlightbox - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(value): - feature_mock.is_on = True - feature_mock.effect = "POLICE" - - feature_mock.async_on = AsyncMock(side_effect=turn_on) - with pytest.raises(HomeAssistantError) as info: await hass.services.async_call( "light", @@ -644,6 +499,7 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None: ) assert info.value.translation_key == "effect_not_found" + feature_mock.async_api_command.assert_not_called() await hass.services.async_call( "light", @@ -652,8 +508,7 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_EFFECT] == "POLICE" + feature_mock.async_api_command.assert_called_once_with("effect", 2) @pytest.mark.parametrize( diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index 45d1d9dc1675..5a24da079bcf 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -304,10 +304,7 @@ async def test_open_status_sensor_none_value( """Test that a None native_value yields an unknown state.""" feature_mock, entity_id = open_status_sensor - def set_none(): - feature_mock.native_value = None - - feature_mock.async_update = AsyncMock(side_effect=set_none) + feature_mock.native_value = None await async_setup_entity(hass, entity_id) state = hass.states.get(entity_id) @@ -380,10 +377,7 @@ async def test_co2_definition_sensor_none_value( """Test that a None native_value yields an unknown state.""" feature_mock, entity_id = co2_definition_sensor - def set_none(): - feature_mock.native_value = None - - feature_mock.async_update = AsyncMock(side_effect=set_none) + feature_mock.native_value = None await async_setup_entity(hass, entity_id) state = hass.states.get(entity_id) diff --git a/tests/components/blebox/test_switch.py b/tests/components/blebox/test_switch.py index 959e68b43e2f..ccd66fad439e 100644 --- a/tests/components/blebox/test_switch.py +++ b/tests/components/blebox/test_switch.py @@ -106,14 +106,8 @@ async def test_switchbox_on(switchbox, hass: HomeAssistant) -> None: feature_mock, entity_id = switchbox - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - def turn_on(): - feature_mock.is_on = True - - feature_mock.async_turn_on = AsyncMock(side_effect=turn_on) - await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -121,8 +115,7 @@ async def test_switchbox_on(switchbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON + feature_mock.async_turn_on.assert_called_once_with() async def test_switchbox_off(switchbox, hass: HomeAssistant) -> None: @@ -130,22 +123,16 @@ async def test_switchbox_off(switchbox, hass: HomeAssistant) -> None: feature_mock, entity_id = switchbox - feature_mock.is_on = True await async_setup_entity(hass, entity_id) - def turn_off(): - feature_mock.is_on = False - - feature_mock.async_turn_off = AsyncMock(side_effect=turn_off) - await hass.services.async_call( "switch", SERVICE_TURN_OFF, {"entity_id": entity_id}, blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF + + feature_mock.async_turn_off.assert_called_once_with() def relay_mock(relay_id=0): @@ -263,14 +250,8 @@ async def test_switchbox_d_turn_first_on(switchbox_d, hass: HomeAssistant) -> No feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = False - feature_mocks[1].is_on = False await async_setup_entities(hass, entity_ids) - def turn_on0(): - feature_mocks[0].is_on = True - - feature_mocks[0].async_turn_on = AsyncMock(side_effect=turn_on0) await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -278,8 +259,8 @@ async def test_switchbox_d_turn_first_on(switchbox_d, hass: HomeAssistant) -> No blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_ON - assert hass.states.get(entity_ids[1]).state == STATE_OFF + feature_mocks[0].async_turn_on.assert_called_once_with() + feature_mocks[1].async_turn_on.assert_not_called() async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None: @@ -287,14 +268,8 @@ async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None: feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = False - feature_mocks[1].is_on = False await async_setup_entities(hass, entity_ids) - def turn_on1(): - feature_mocks[1].is_on = True - - feature_mocks[1].async_turn_on = AsyncMock(side_effect=turn_on1) await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -302,8 +277,8 @@ async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None: blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_OFF - assert hass.states.get(entity_ids[1]).state == STATE_ON + feature_mocks[0].async_turn_on.assert_not_called() + feature_mocks[1].async_turn_on.assert_called_once_with() async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None: @@ -311,14 +286,8 @@ async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None: feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = True - feature_mocks[1].is_on = True await async_setup_entities(hass, entity_ids) - def turn_off0(): - feature_mocks[0].is_on = False - - feature_mocks[0].async_turn_off = AsyncMock(side_effect=turn_off0) await hass.services.async_call( "switch", SERVICE_TURN_OFF, @@ -326,8 +295,8 @@ async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None: blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_OFF - assert hass.states.get(entity_ids[1]).state == STATE_ON + feature_mocks[0].async_turn_off.assert_called_once_with() + feature_mocks[1].async_turn_off.assert_not_called() async def test_switchbox_d_second_off(switchbox_d, hass: HomeAssistant) -> None: @@ -335,22 +304,17 @@ async def test_switchbox_d_second_off(switchbox_d, hass: HomeAssistant) -> None: feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = True - feature_mocks[1].is_on = True await async_setup_entities(hass, entity_ids) - def turn_off1(): - feature_mocks[1].is_on = False - - feature_mocks[1].async_turn_off = AsyncMock(side_effect=turn_off1) await hass.services.async_call( "switch", SERVICE_TURN_OFF, {"entity_id": entity_ids[1]}, blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_ON - assert hass.states.get(entity_ids[1]).state == STATE_OFF + + feature_mocks[0].async_turn_off.assert_not_called() + feature_mocks[1].async_turn_off.assert_called_once_with() async def test_switchbox_with_name(hass: HomeAssistant) -> None: diff --git a/tests/components/calendar/test_llm.py b/tests/components/calendar/test_llm.py new file mode 100644 index 000000000000..056ff357d69e --- /dev/null +++ b/tests/components/calendar/test_llm.py @@ -0,0 +1,172 @@ +"""Tests for the calendar LLM tools platform.""" + +from datetime import timedelta + +from freezegun import freeze_time +import pytest + +from homeassistant.components import calendar, llm as llm_component +from homeassistant.components.calendar import llm as calendar_llm +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant, SupportsResponse +from homeassistant.helpers import entity_registry as er, llm +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import async_mock_service + +ENTITY_ID = "calendar.test_calendar" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a calendar.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "calendar", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Mock Calendar Name"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_get_tools_no_exposed_calendar(hass: HomeAssistant) -> None: + """Test no calendar tool is offered when no calendar is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "calendar_get_events" not in [tool.name for tool in result.tools] + assert calendar_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert calendar_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: + """Test the calendar get events tool is exposed and works via the platform.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next( + (tool for tool in result.tools if tool.name == "calendar_get_events"), None + ) + assert tool is not None + assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] + + calls = async_mock_service( + hass, + domain=calendar.DOMAIN, + service=calendar.SERVICE_GET_EVENTS, + schema=calendar.SERVICE_GET_EVENTS_SCHEMA, + response={ + ENTITY_ID: { + "events": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ] + } + }, + supports_response=SupportsResponse.ONLY, + ) + + tool_input = llm.ToolInput( + tool_name="calendar_get_events", + tool_args={"calendar": "Mock Calendar Name", "range": "today"}, + ) + now = dt_util.now() + with freeze_time(now): + response = await tool.async_call(hass, tool_input, llm_context) + + assert len(calls) == 1 + call = calls[0] + assert call.domain == calendar.DOMAIN + assert call.service == calendar.SERVICE_GET_EVENTS + assert call.data == { + "entity_id": [ENTITY_ID], + "start_date_time": now, + "end_date_time": dt_util.start_of_local_day(now) + timedelta(days=1), + } + + assert response == { + "success": True, + "result": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + "all_day": True, + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ], + } + + # The "week" range searches seven days out. + calls.clear() + tool_input.tool_args["range"] = "week" + with freeze_time(now): + await tool.async_call(hass, tool_input, llm_context) + assert call.domain == calendar.DOMAIN + assert calls[0].data["end_date_time"] == ( + dt_util.start_of_local_day(now) + timedelta(days=7) + ) + + +async def test_calendar_get_events_tool_not_found(hass: HomeAssistant) -> None: + """Test the tool reports when the requested calendar no longer matches.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next(tool for tool in result.tools if tool.name == "calendar_get_events") + + # Unexpose after the tool (and its calendar enum) was built, so the call-time + # match no longer finds the calendar. + async_expose_entity(hass, "conversation", ENTITY_ID, False) + response = await tool.async_call( + hass, + llm.ToolInput( + "calendar_get_events", {"calendar": "Mock Calendar Name", "range": "today"} + ), + llm_context, + ) + assert response == {"success": False, "error": "Calendar not found"} + + +async def test_calendar_get_events_tool_uses_aliases( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test exposed calendar aliases are offered as valid tool values.""" + entry = entity_registry.async_get_or_create( + "calendar", "test", "aliased", suggested_object_id="aliased" + ) + entity_registry.async_update_entity(entry.entity_id, aliases={"Family Calendar"}) + hass.states.async_set(entry.entity_id, "on") + async_expose_entity(hass, "conversation", entry.entity_id, True) + + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tool = next(tool for tool in result.tools if tool.name == "calendar_get_events") + assert "Family Calendar" in tool.parameters.schema["calendar"].container diff --git a/tests/components/cielo_home/snapshots/test_sensor.ambr b/tests/components/cielo_home/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..fc17c32ff764 --- /dev/null +++ b/tests/components/cielo_home/snapshots/test_sensor.ambr @@ -0,0 +1,114 @@ +# serializer version: 1 +# name: test_all_entities[sensor.living_room_living_room_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_living_room_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Humidity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'cielo_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'device_1-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.living_room_living_room_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'humidity', + : 'Living Room Humidity', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_living_room_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[sensor.living_room_living_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_living_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'cielo_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'device_1-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.living_room_living_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_room_living_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- diff --git a/tests/components/cielo_home/test_sensor.py b/tests/components/cielo_home/test_sensor.py new file mode 100644 index 000000000000..aff732ae9154 --- /dev/null +++ b/tests/components/cielo_home/test_sensor.py @@ -0,0 +1,89 @@ +"""Tests for the Cielo Home sensor platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("mock_cielo_client", "mock_cielo_device_api") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all sensor entities.""" + with patch("homeassistant.components.cielo_home.PLATFORMS", [Platform.SENSOR]): + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_cielo_client") +@pytest.mark.parametrize( + ("temperature_unit", "hass_units", "expected_unit"), + [ + pytest.param( + "°F", + US_CUSTOMARY_SYSTEM, + UnitOfTemperature.FAHRENHEIT, + id="fahrenheit", + ), + pytest.param( + "unknown", + METRIC_SYSTEM, + UnitOfTemperature.CELSIUS, + id="unknown_unit", + ), + pytest.param( + None, + METRIC_SYSTEM, + UnitOfTemperature.CELSIUS, + id="none_unit", + ), + ], +) +async def test_temperature_sensor_unit( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cielo_device_api: MagicMock, + entity_registry: er.EntityRegistry, + temperature_unit: str | None, + hass_units: UnitSystem, + expected_unit: str, +) -> None: + """Test temperature sensor reports the correct unit.""" + mock_cielo_device_api.temperature_unit.return_value = temperature_unit + hass.config.units = hass_units + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.living_room_living_room_temperature") + assert state is not None + assert state.attributes.get("unit_of_measurement") == expected_unit + + entry = entity_registry.async_get("sensor.living_room_living_room_temperature") + assert entry is not None + assert entry.unit_of_measurement == expected_unit diff --git a/tests/components/climate/test_llm.py b/tests/components/climate/test_llm.py new file mode 100644 index 000000000000..85103a855768 --- /dev/null +++ b/tests/components/climate/test_llm.py @@ -0,0 +1,58 @@ +"""Tests for the climate LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.climate import llm as climate_llm +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "climate.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a climate entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "climate", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test climate"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the climate platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed climate entity.""" + assert "HassClimateSetTemperature" in await _tool_names(hass) + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no climate entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert "HassClimateSetTemperature" not in await _tool_names(hass) + assert climate_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert climate_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index e2aca44ee2b1..112af447333b 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -615,3 +615,31 @@ async def test_browse( assert msg["result"]["title"] == "media" assert msg["result"]["media_class"] == "directory" assert len(msg["result"]["children"]) + + +async def test_search( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the media player search delegates to media source.""" + entity = "media_player.browse" + + await async_setup_component(hass, "media_source", {"media_source": {}}) + assert await async_setup_component( + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": entity, + "search_query": "test", + } + ) + + msg = await websocket_client.receive_json() + assert msg["success"] + assert [item["title"] for item in msg["result"]["result"]] == ["test.mp3"] diff --git a/tests/components/evohome/fixtures/botched/user_locations.json b/tests/components/evohome/fixtures/botched/user_locations.json index 0016c5db0075..3f85001f7053 100644 --- a/tests/components/evohome/fixtures/botched/user_locations.json +++ b/tests/components/evohome/fixtures/botched/user_locations.json @@ -246,7 +246,7 @@ }, { "zoneId": "3450733", - "modelType": "xxx", + "modelType": "Unknown", "setpointCapabilities": { "maxHeatSetpoint": 35.0, "minHeatSetpoint": 5.0, @@ -268,7 +268,7 @@ "setpointValueResolution": 0.5 }, "name": "Spare Room", - "zoneType": "xxx" + "zoneType": "Unknown" } ], "dhw": { diff --git a/tests/components/evohome/fixtures/h032585/user_locations.json b/tests/components/evohome/fixtures/h032585/user_locations.json index c291d591c997..425d080af54d 100644 --- a/tests/components/evohome/fixtures/h032585/user_locations.json +++ b/tests/components/evohome/fixtures/h032585/user_locations.json @@ -3,6 +3,11 @@ "locationInfo": { "locationId": "111111", "name": "My Home", + "streetAddress": "1 Main Street", + "city": "London", + "country": "UnitedKingdom", + "postcode": "E1 1AA", + "locationType": "Residential", "useDaylightSaveSwitching": true, "timeZone": { "timeZoneId": "GMTStandardTime", @@ -10,6 +15,12 @@ "offsetMinutes": 0, "currentOffsetMinutes": 60, "supportsDaylightSaving": true + }, + "locationOwner": { + "userId": "2263181", + "username": "user_2263181@gmail.com", + "firstname": "John", + "lastname": "Smith" } }, "gateways": [ diff --git a/tests/components/evohome/fixtures/h099625/user_locations.json b/tests/components/evohome/fixtures/h099625/user_locations.json index 31cac00ae9ee..d8ecc8a0aa48 100644 --- a/tests/components/evohome/fixtures/h099625/user_locations.json +++ b/tests/components/evohome/fixtures/h099625/user_locations.json @@ -3,6 +3,11 @@ "locationInfo": { "locationId": "111111", "name": "My Home", + "streetAddress": "1 Main Street", + "city": "Helsinki", + "country": "Finland", + "postcode": "E1 1AA", + "locationType": "Residential", "useDaylightSaveSwitching": true, "timeZone": { "timeZoneId": "FLEStandardTime", @@ -10,6 +15,12 @@ "offsetMinutes": 120, "currentOffsetMinutes": 180, "supportsDaylightSaving": true + }, + "locationOwner": { + "userId": "2263181", + "username": "user_2263181@gmail.com", + "firstname": "John", + "lastname": "Smith" } }, "gateways": [ diff --git a/tests/components/evohome/fixtures/sys_004/status_3164610.json b/tests/components/evohome/fixtures/sys_004/status_3164610.json index a9ef3f6ee28a..9624d66c0cbd 100644 --- a/tests/components/evohome/fixtures/sys_004/status_3164610.json +++ b/tests/components/evohome/fixtures/sys_004/status_3164610.json @@ -19,7 +19,11 @@ } ], "activeFaults": [], - "systemModeStatus": { "mode": "Auto", "isPermanent": true } + "systemModeStatus": { + "mode": "Away", + "timeUntil": "2023-06-29T23:00:00Z", + "isPermanent": false + } } ], "activeFaults": [ diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index 174f94ae51fb..b3ca58549c4f 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -2,168 +2,168 @@ # name: test_ctl_set_hvac_mode[default] list([ tuple( - , + , ), tuple( - , + , ), ]) # --- # name: test_ctl_set_hvac_mode[h032585] list([ tuple( - , + , ), tuple( - , + , ), ]) # --- # name: test_ctl_set_hvac_mode[h099625] list([ tuple( - , + , ), tuple( - , + , ), ]) # --- # name: test_ctl_set_hvac_mode[h139906] list([ tuple( - , + , ), tuple( - , + , ), ]) # --- # name: test_ctl_set_hvac_mode[h157546] list([ tuple( - , + , ), tuple( - , + , ), ]) # --- # name: test_ctl_set_hvac_mode[minimal] list([ tuple( - , + , ), tuple( - , + , ), ]) # --- # name: test_ctl_set_hvac_mode[sys_004] list([ tuple( - , + , ), tuple( - , + , ), ]) # --- # name: test_ctl_turn_off[default] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_off[h032585] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_off[h099625] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_off[h139906] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_off[h157546] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_off[minimal] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_off[sys_004] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_on[default] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_on[h032585] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_on[h099625] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_on[h139906] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_on[h157546] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_on[minimal] list([ tuple( - , + , ), ]) # --- # name: test_ctl_turn_on[sys_004] list([ tuple( - , + , ), ]) # --- @@ -1337,7 +1337,7 @@ # name: test_setup_platform[botched][climate.my_home-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 19.7, + : 19.8, : 'My Home', : list([ , @@ -2498,7 +2498,7 @@ : 'mdi:thermostat-box', : 35, : 7, - : None, + : 'away', : list([ 'eco', 'away', @@ -2512,8 +2512,9 @@ ), 'system_id': '4187769', 'system_mode_status': dict({ - 'is_permanent': True, - 'mode': 'Auto', + 'is_permanent': False, + 'mode': 'Away', + 'time_until': '2023-06-30T01:00:00+02:00', }), }), : , @@ -2537,7 +2538,7 @@ ]), : 35.0, : 5.0, - : 'permanent', + : 'away', : list([ 'none', 'temporary', @@ -2570,7 +2571,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'heat', + 'state': 'auto', }) # --- # name: test_zone_set_hvac_mode[default] diff --git a/tests/components/evohome/test_climate.py b/tests/components/evohome/test_climate.py index 5aef5ab1005a..138678b20dc9 100644 --- a/tests/components/evohome/test_climate.py +++ b/tests/components/evohome/test_climate.py @@ -115,9 +115,9 @@ async def test_ctl_set_hvac_mode( ) try: - mock_fcn.assert_awaited_once_with("HeatingOff", until=None) + mock_fcn.assert_awaited_once_with("heating_off", until=None) except AssertionError: - mock_fcn.assert_awaited_once_with("Off", until=None) + mock_fcn.assert_awaited_once_with("off", until=None) results.append(mock_fcn.await_args.args) # type: ignore[union-attr] @@ -134,9 +134,9 @@ async def test_ctl_set_hvac_mode( ) try: - mock_fcn.assert_awaited_once_with("Auto", until=None) + mock_fcn.assert_awaited_once_with("auto", until=None) except AssertionError: - mock_fcn.assert_awaited_once_with("Heat", until=None) + mock_fcn.assert_awaited_once_with("heat", until=None) results.append(mock_fcn.await_args.args) # type: ignore[union-attr] @@ -185,9 +185,9 @@ async def test_ctl_turn_off( ) try: - mock_fcn.assert_awaited_once_with("HeatingOff", until=None) + mock_fcn.assert_awaited_once_with("heating_off", until=None) except AssertionError: - mock_fcn.assert_awaited_once_with("Off", until=None) + mock_fcn.assert_awaited_once_with("off", until=None) results.append(mock_fcn.await_args.args) # type: ignore[union-attr] @@ -242,9 +242,9 @@ async def test_ctl_turn_on( ) try: - mock_fcn.assert_awaited_once_with("Auto", until=None) + mock_fcn.assert_awaited_once_with("auto", until=None) except AssertionError: - mock_fcn.assert_awaited_once_with("Heat", until=None) + mock_fcn.assert_awaited_once_with("heat", until=None) results.append(mock_fcn.await_args.args) # type: ignore[union-attr] @@ -270,7 +270,7 @@ async def test_ctl_preset_reset_deprecated( blocking=True, ) - mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) + mock_fcn.assert_awaited_once_with("auto_with_reset", until=None) issue = issue_registry.async_get_issue(DOMAIN, "deprecated_preset_reset") assert issue is not None diff --git a/tests/components/evohome/test_services.py b/tests/components/evohome/test_services.py index f5ac8de6f548..eab9cbf81515 100644 --- a/tests/components/evohome/test_services.py +++ b/tests/components/evohome/test_services.py @@ -4,15 +4,13 @@ from datetime import UTC, datetime from typing import Any from unittest.mock import patch +from evohomeasync2.const import SZ_DURATION, SZ_MODE, SZ_PERIOD, SZ_SETPOINT, SZ_STATE from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.evohome.climate import EvoZone from homeassistant.components.evohome.const import ( - ATTR_DURATION, - ATTR_PERIOD, - ATTR_SETPOINT, DOMAIN, REFRESH_BREAKS_IN_HA_VERSION, RESET_BREAKS_IN_HA_VERSION, @@ -21,7 +19,7 @@ from homeassistant.components.evohome.const import ( ) from homeassistant.components.evohome.water_heater import EvoDHW from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, ATTR_STATE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir @@ -133,12 +131,12 @@ async def test_set_system_mode_deprecated( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Auto", + SZ_MODE: "Auto", }, blocking=True, ) - mock_fcn.assert_awaited_once_with("Auto", until=None) + mock_fcn.assert_awaited_once_with("auto", until=None) issue = issue_registry.async_get_issue(DOMAIN, "deprecated_set_system_mode_service") assert issue @@ -156,14 +154,14 @@ async def test_set_system_mode_deprecated( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "AutoWithEco", - ATTR_DURATION: {"hours": 12}, + SZ_MODE: "AutoWithEco", + SZ_DURATION: {"hours": 12}, }, blocking=True, ) mock_fcn.assert_awaited_once_with( - "AutoWithEco", until=datetime(2024, 7, 11, 0, 0, tzinfo=UTC) + "auto_with_eco", until=datetime(2024, 7, 11, 0, 0, tzinfo=UTC) ) # EvoService.SET_SYSTEM_MODE: Away, days=7 @@ -172,14 +170,14 @@ async def test_set_system_mode_deprecated( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Away", - ATTR_PERIOD: {"days": 7}, + SZ_MODE: "Away", + SZ_PERIOD: {"days": 7}, }, blocking=True, ) mock_fcn.assert_awaited_once_with( - "Away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC) + "away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC) ) @@ -199,15 +197,15 @@ async def test_set_system_mode( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Away", - ATTR_PERIOD: {"days": 7}, + SZ_MODE: "Away", + SZ_PERIOD: {"days": 7}, }, target={ATTR_ENTITY_ID: ctl_id}, blocking=True, ) mock_fcn.assert_awaited_once_with( - "Away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC) + "away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC) ) # can remove, once the domain-level service is removed @@ -217,14 +215,14 @@ async def test_set_system_mode( EvoService.SET_SYSTEM_MODE, { ATTR_ENTITY_ID: ctl_id, - ATTR_MODE: "Away", - ATTR_PERIOD: {"days": 7}, + SZ_MODE: "Away", + SZ_PERIOD: {"days": 7}, }, blocking=True, ) mock_fcn.assert_awaited_once_with( - "Away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC) + "away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC) ) issue = issue_registry.async_get_issue(DOMAIN, "deprecated_set_system_mode_service") @@ -308,7 +306,7 @@ async def test_set_zone_override( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_SETPOINT: 19.5, + SZ_SETPOINT: 19.5, }, target={ATTR_ENTITY_ID: zone_id}, blocking=True, @@ -322,8 +320,8 @@ async def test_set_zone_override( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_SETPOINT: 19.5, - ATTR_DURATION: {"minutes": 135}, + SZ_SETPOINT: 19.5, + SZ_DURATION: {"minutes": 135}, }, target={ATTR_ENTITY_ID: zone_id}, blocking=True, @@ -363,8 +361,8 @@ async def test_set_zone_override_advance( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_SETPOINT: 19.5, - ATTR_DURATION: {"minutes": 0}, + SZ_SETPOINT: 19.5, + SZ_DURATION: {"minutes": 0}, }, target={ATTR_ENTITY_ID: zone_id}, blocking=True, @@ -392,7 +390,7 @@ async def test_set_zone_override_legacy( EvoService.SET_ZONE_OVERRIDE, { ATTR_ENTITY_ID: zone_id, - ATTR_SETPOINT: 19.5, + SZ_SETPOINT: 19.5, }, blocking=True, ) @@ -406,8 +404,8 @@ async def test_set_zone_override_legacy( EvoService.SET_ZONE_OVERRIDE, { ATTR_ENTITY_ID: zone_id, - ATTR_SETPOINT: 19.5, - ATTR_DURATION: {"minutes": 135}, + SZ_SETPOINT: 19.5, + SZ_DURATION: {"minutes": 135}, }, blocking=True, ) @@ -422,7 +420,7 @@ async def test_set_zone_override_legacy( ("service", "service_data"), [ (EvoService.CLEAR_ZONE_OVERRIDE, {}), - (EvoService.SET_ZONE_OVERRIDE, {ATTR_SETPOINT: 19.5}), + (EvoService.SET_ZONE_OVERRIDE, {SZ_SETPOINT: 19.5}), ], ) async def test_zone_services_with_ctl_id( @@ -458,7 +456,7 @@ async def test_controller_services_with_zone_id( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Auto", + SZ_MODE: "Auto", ATTR_ENTITY_ID: zone_id, }, blocking=True, @@ -482,7 +480,7 @@ async def test_set_system_mode_entity_not_found(hass: HomeAssistant) -> None: DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Auto", + SZ_MODE: "Auto", ATTR_ENTITY_ID: non_existent_entity_id, }, blocking=True, @@ -496,19 +494,19 @@ async def test_set_system_mode_entity_not_found(hass: HomeAssistant) -> None: _SET_SYSTEM_MODE_VALIDATOR_PARAMS = [ ( - {ATTR_MODE: "NotARealMode"}, + {SZ_MODE: "NotARealMode"}, "mode_not_supported", ), ( - {ATTR_MODE: "Auto", ATTR_DURATION: {"hours": 1}}, + {SZ_MODE: "Auto", SZ_DURATION: {"hours": 1}}, "mode_cant_be_temporary", ), ( - {ATTR_MODE: "AutoWithEco", ATTR_PERIOD: {"days": 1}}, + {SZ_MODE: "AutoWithEco", SZ_PERIOD: {"days": 1}}, "mode_cant_have_period", ), ( - {ATTR_MODE: "DayOff", ATTR_DURATION: {"hours": 1}}, + {SZ_MODE: "DayOff", SZ_DURATION: {"hours": 1}}, "mode_cant_have_duration", ), ] @@ -537,9 +535,7 @@ async def test_set_system_mode_validator( ) assert exc_info.value.translation_key == expected_translation_key - assert exc_info.value.translation_placeholders == { - ATTR_MODE: service_data[ATTR_MODE] - } + assert exc_info.value.translation_placeholders == {SZ_MODE: service_data[SZ_MODE]} @pytest.mark.parametrize("install", ["default"]) @@ -558,7 +554,7 @@ async def test_set_dhw_override( DOMAIN, EvoService.SET_DHW_OVERRIDE, { - ATTR_STATE: False, + SZ_STATE: False, }, target={ATTR_ENTITY_ID: dhw_id}, blocking=True, @@ -572,8 +568,8 @@ async def test_set_dhw_override( DOMAIN, EvoService.SET_DHW_OVERRIDE, { - ATTR_STATE: True, - ATTR_DURATION: {"minutes": 135}, + SZ_STATE: True, + SZ_DURATION: {"minutes": 135}, }, target={ATTR_ENTITY_ID: dhw_id}, blocking=True, @@ -613,8 +609,8 @@ async def test_set_dhw_override_advance( DOMAIN, EvoService.SET_DHW_OVERRIDE, { - ATTR_STATE: True, - ATTR_DURATION: {"minutes": 0}, + SZ_STATE: True, + SZ_DURATION: {"minutes": 0}, }, target={ATTR_ENTITY_ID: dhw_id}, blocking=True, diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 49910ea1b251..792cc7ba15b5 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -3,9 +3,15 @@ from datetime import datetime, timedelta from typing import Any, Final, NotRequired, TypedDict +from evohomeasync2.auth import ( + SZ_ACCESS_TOKEN, + SZ_ACCESS_TOKEN_EXPIRES, + SZ_REFRESH_TOKEN, +) import pytest from homeassistant.components.evohome.const import DOMAIN, STORAGE_KEY, STORAGE_VER +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -30,10 +36,6 @@ class _EmptyStoreT(TypedDict): pass -SZ_USERNAME: Final = "username" -SZ_REFRESH_TOKEN: Final = "refresh_token" -SZ_ACCESS_TOKEN: Final = "access_token" -SZ_ACCESS_TOKEN_EXPIRES: Final = "access_token_expires" SZ_USER_DATA: Final = "user_data" @@ -49,7 +51,7 @@ USERNAME_DIFF: Final = f"not_{USERNAME}" USERNAME_SAME: Final = USERNAME _TEST_STORAGE_BASE: Final[_TokenStoreT] = { - SZ_USERNAME: USERNAME_SAME, + CONF_USERNAME: USERNAME_SAME, SZ_REFRESH_TOKEN: REFRESH_TOKEN, SZ_ACCESS_TOKEN: ACCESS_TOKEN, SZ_ACCESS_TOKEN_EXPIRES: ACCESS_TOKEN_EXP_STR, @@ -92,7 +94,7 @@ async def test_auth_tokens_null( # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_SAME + assert data[CONF_USERNAME] == USERNAME_SAME assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" assert ( @@ -120,7 +122,7 @@ async def test_auth_tokens_same( # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_SAME + assert data[CONF_USERNAME] == USERNAME_SAME assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN assert data[SZ_ACCESS_TOKEN] == ACCESS_TOKEN assert dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES]) == ACCESS_TOKEN_EXP_DTM @@ -151,7 +153,7 @@ async def test_auth_tokens_past( # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_SAME + assert data[CONF_USERNAME] == USERNAME_SAME assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" assert ( @@ -180,7 +182,7 @@ async def test_auth_tokens_diff( # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_DIFF + assert data[CONF_USERNAME] == USERNAME_DIFF assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" assert ( diff --git a/tests/components/fan/test_llm.py b/tests/components/fan/test_llm.py new file mode 100644 index 000000000000..92f95aad4651 --- /dev/null +++ b/tests/components/fan/test_llm.py @@ -0,0 +1,58 @@ +"""Tests for the fan LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.fan import llm as fan_llm +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "fan.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a fan entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "fan", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test fan"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the fan platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed fan entity.""" + assert "HassFanSetSpeed" in await _tool_names(hass) + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no fan entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert "HassFanSetSpeed" not in await _tool_names(hass) + assert fan_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert fan_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/fints/__init__.py b/tests/components/fints/__init__.py index 6a2b1d96d206..b733febd899e 100644 --- a/tests/components/fints/__init__.py +++ b/tests/components/fints/__init__.py @@ -1 +1 @@ -"""Tests for FinTS component.""" +"""Tests for FinTS integration.""" diff --git a/tests/components/foobot/__init__.py b/tests/components/foobot/__init__.py index 88d916d997f1..a39cf589a88f 100644 --- a/tests/components/foobot/__init__.py +++ b/tests/components/foobot/__init__.py @@ -1 +1 @@ -"""Tests for the foobot component.""" +"""Tests for the Foobot integration.""" diff --git a/tests/components/gpslogger/__init__.py b/tests/components/gpslogger/__init__.py index 636a9a767f95..822253c30a11 100644 --- a/tests/components/gpslogger/__init__.py +++ b/tests/components/gpslogger/__init__.py @@ -1 +1 @@ -"""Tests for the GPSLogger component.""" +"""Tests for the GPSLogger integration.""" diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 4755c9031239..a172debf065b 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -64,7 +64,7 @@ async def setup_zones(hass: HomeAssistant) -> None: @pytest.fixture async def webhook_id(hass: HomeAssistant, gpslogger_client: TestClient) -> str: - """Initialize the GPSLogger component and get the webhook_id.""" + """Initialize the GPSLogger integration and get the webhook_id.""" await async_process_ha_core_config( hass, {"internal_url": "http://example.local:8123"}, diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 72218dc50ed4..148988f8a9ad 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -46,7 +46,7 @@ dict({ 'can_expand': True, 'can_play': False, - 'can_search': False, + 'can_search': True, 'children': list([ dict({ 'can_expand': False, @@ -100,7 +100,7 @@ dict({ 'can_expand': True, 'can_play': False, - 'can_search': False, + 'can_search': True, 'children_media_class': 'music', 'media_class': 'directory', 'media_content_id': 'media-source://media_source/local/.', diff --git a/tests/components/homee/test_init.py b/tests/components/homee/test_init.py index 66c4effe49ae..b601b4de294e 100644 --- a/tests/components/homee/test_init.py +++ b/tests/components/homee/test_init.py @@ -147,9 +147,9 @@ async def test_attribute_availability( await hass.async_block_till_done() assert ( - hass.states.get("siren.test_siren").state is not STATE_UNAVAILABLE + hass.states.get("siren.test_siren").state != STATE_UNAVAILABLE if state < AttributeState.INACTIVE - else STATE_UNAVAILABLE + else hass.states.get("siren.test_siren").state == STATE_UNAVAILABLE ) diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index c356ec0e587d..7d0cc7666fb5 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -19848,7 +19848,7 @@ 'area_id': None, 'capabilities': dict({ : list([ - 'single_press', + 'ring', 'double_press', 'long_press', ]), @@ -19887,7 +19887,7 @@ : 'doorbell', : None, : list([ - 'single_press', + 'ring', 'double_press', 'long_press', ]), diff --git a/tests/components/homekit_controller/test_event.py b/tests/components/homekit_controller/test_event.py index 2254845964a5..9a1b481ffd9e 100644 --- a/tests/components/homekit_controller/test_event.py +++ b/tests/components/homekit_controller/test_event.py @@ -168,7 +168,7 @@ async def test_doorbell( assert doorbell.original_device_class == EventDeviceClass.DOORBELL assert doorbell.capabilities["event_types"] == [ - "single_press", + "ring", "double_press", "long_press", ] @@ -178,7 +178,7 @@ async def test_doorbell( ) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes["event_type"] == "single_press" + assert state.attributes["event_type"] == "ring" helper.pairing.testing.update_named_service( "Doorbell", {CharacteristicsTypes.INPUT_EVENT: 1} diff --git a/tests/components/homematicip_cloud/__init__.py b/tests/components/homematicip_cloud/__init__.py index 1d89bd73183c..542a985b63ed 100644 --- a/tests/components/homematicip_cloud/__init__.py +++ b/tests/components/homematicip_cloud/__init__.py @@ -1 +1 @@ -"""Tests for the HomematicIP Cloud component.""" +"""Tests for the HomematicIP Cloud integration.""" diff --git a/tests/components/infrared/common.py b/tests/components/infrared/common.py index 793dcc681d07..a5a8be790fbb 100644 --- a/tests/components/infrared/common.py +++ b/tests/components/infrared/common.py @@ -33,11 +33,12 @@ class MockInfraredEmitterEntity(InfraredEmitterEntity): """Mock infrared emitter entity for testing.""" _attr_has_entity_name = True - _attr_name = "Test IR emitter" - def __init__(self, unique_id: str) -> None: + def __init__(self, unique_id: str, name: str | None = "Test IR emitter") -> None: """Initialize mock entity.""" self._attr_unique_id = unique_id + if name is not None: + self._attr_name = name self.send_command_calls: list[InfraredCommand] = [] async def async_send_command(self, command: InfraredCommand) -> None: @@ -49,11 +50,12 @@ class MockInfraredReceiverEntity(InfraredReceiverEntity): """Mock infrared receiver entity for testing.""" _attr_has_entity_name = True - _attr_name = "Test IR receiver" - def __init__(self, unique_id: str) -> None: + def __init__(self, unique_id: str, name: str | None = "Test IR receiver") -> None: """Initialize mock receiver entity.""" self._attr_unique_id = unique_id + if name is not None: + self._attr_name = name async def init_infrared_fixture_helper(hass: HomeAssistant) -> None: diff --git a/tests/components/infrared/test_init.py b/tests/components/infrared/test_init.py index 71d7d96040d6..902f1035877d 100644 --- a/tests/components/infrared/test_init.py +++ b/tests/components/infrared/test_init.py @@ -16,15 +16,27 @@ from homeassistant.components.infrared import ( async_send_command, async_subscribe_receiver, ) -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import MockInfraredEmitterEntity, MockInfraredReceiverEntity -from tests.common import mock_restore_cache +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, +) + +TEST_DOMAIN = "test" TEST_COMMAND = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) @@ -293,3 +305,91 @@ async def test_async_subscribe_receiver_component_not_loaded( """Test async_subscribe_receiver raises error when component not loaded.""" with pytest.raises(HomeAssistantError, match="component_not_loaded"): async_subscribe_receiver(hass, "infrared.some_entity", lambda _: None) + + +@pytest.mark.usefixtures("init_infrared") +async def test_name(hass: HomeAssistant) -> None: + """Test entity name / device class naming fallback.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.INFRARED] + ) + return True + + class MockFlow(ConfigFlow): + """Test flow.""" + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed emitter without has_entity_name -> no name + emitter1 = MockInfraredEmitterEntity("test_emitter1", name=None) + emitter1.entity_id = "infrared.test_emitter1" + emitter1._attr_has_entity_name = False + + # Unnamed emitter with has_entity_name True -> name set from device class + emitter2 = MockInfraredEmitterEntity("test_emitter2", name=None) + emitter2.entity_id = "infrared.test_emitter2" + emitter2._attr_has_entity_name = True + + # Unnamed receiver without has_entity_name -> no name + receiver1 = MockInfraredReceiverEntity("test_receiver1", name=None) + receiver1.entity_id = "infrared.test_receiver1" + receiver1._attr_has_entity_name = False + + # Unnamed receiver with has_entity_name True -> name set from device class + receiver2 = MockInfraredReceiverEntity("test_receiver2", name=None) + receiver2.entity_id = "infrared.test_receiver2" + receiver2._attr_has_entity_name = True + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test infrared platform via config entry.""" + async_add_entities([emitter1, emitter2, receiver1, receiver2]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + with mock_config_flow(TEST_DOMAIN, MockFlow): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state1 = hass.states.get("infrared.test_emitter1") + assert state1 is not None + assert state1.attributes == {"device_class": "emitter"} + + state2 = hass.states.get("infrared.test_emitter2") + assert state2 is not None + assert state2.attributes == { + "device_class": "emitter", + "friendly_name": "Infrared emitter", + } + + state3 = hass.states.get("infrared.test_receiver1") + assert state3 is not None + assert state3.attributes == {"device_class": "receiver"} + + state4 = hass.states.get("infrared.test_receiver2") + assert state4 is not None + assert state4.attributes == { + "device_class": "receiver", + "friendly_name": "Infrared receiver", + } diff --git a/tests/components/intent_script/test_llm.py b/tests/components/intent_script/test_llm.py new file mode 100644 index 000000000000..c72d479cee38 --- /dev/null +++ b/tests/components/intent_script/test_llm.py @@ -0,0 +1,99 @@ +"""Tests for the intent_script LLM tools platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.intent_script import ( + ScriptIntentHandler, + llm as intent_script_llm, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import intent, llm +from homeassistant.setup import async_setup_component + +LIGHT_ENTITY_ID = "light.kitchen" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and configure intent scripts.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "Tell a joke": { + "description": "Tell a joke", + "speech": {"text": "Why did the chicken cross the road?"}, + }, + "LightAction": { + "description": "Do a light thing", + "platforms": ["light"], + "speech": {"text": "Done"}, + }, + } + }, + ) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(LIGHT_ENTITY_ID, "on", {"friendly_name": "Kitchen Light"}) + async_expose_entity(hass, "conversation", LIGHT_ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the intent_script platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_scripts_exposed(hass: HomeAssistant) -> None: + """Test intent scripts are exposed as LLM tools with slugified names.""" + names = await _tool_names(hass) + # The user-provided "Tell a joke" name is slugified into a valid tool name. + assert "Tell_a_joke" in names + assert "LightAction" in names + + +async def test_intent_script_platform_filtered(hass: HomeAssistant) -> None: + """Test a platform-restricted intent script requires an exposed entity.""" + async_expose_entity(hass, "conversation", LIGHT_ENTITY_ID, False) + names = await _tool_names(hass) + assert "LightAction" not in names + # Unrestricted intent scripts stay exposed. + assert "Tell_a_joke" in names + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert intent_script_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_no_tools_when_no_scripts_match(hass: HomeAssistant) -> None: + """Test None is returned when no intent scripts match the exposed domains.""" + async_expose_entity(hass, "conversation", LIGHT_ENTITY_ID, False) + restricted_handlers = [ + handler + for handler in intent.async_get(hass) + if isinstance(handler, ScriptIntentHandler) and handler.platforms + ] + with patch( + "homeassistant.components.intent_script.llm.intent.async_get", + return_value=restricted_handlers, + ): + assert intent_script_llm.async_get_tools(hass, _llm_context(), "assist") is None diff --git a/tests/components/knx/fixtures/config_store_button.json b/tests/components/knx/fixtures/config_store_button.json new file mode 100644 index 000000000000..41adf5d283d8 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_button.json @@ -0,0 +1,44 @@ +{ + "version": 2, + "minor_version": 3, + "key": "knx/config_store.json", + "data": { + "entities": { + "button": { + "knx_es_01KVFEGP54VJW94TR9GQW2XA4R": { + "entity": { + "name": "test raw", + "device_info": null, + "entity_category": null + }, + "knx": { + "data": { + "payload": "0x1", + "payload_length": 1 + }, + "ga_send": { + "write": "1/1/1" + } + } + }, + "knx_es_01KVFEHE937CQGWP81RZNQ6D8E": { + "entity": { + "name": "test typed", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_send": { + "write": "1/1/2", + "dpt": "1.001" + }, + "data": { + "value": "on" + } + } + } + } + }, + "time_server": {} + } +} diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 072bfa46f0be..b355dcc3b51e 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -129,6 +129,39 @@ 'type': 'result', }) # --- +# name: test_knx_get_schema[button] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'name': 'ga_send', + 'options': dict({ + 'dptClasses': list([ + 'numeric', + 'enum', + 'complex', + 'string', + ]), + 'passive': False, + 'state': False, + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'ga_path': 'ga_send', + 'name': 'data', + 'required': True, + 'type': 'knx_payload', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[climate] dict({ 'id': 1, diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index 38ccb36200b0..21d6e136e74b 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -2,22 +2,32 @@ from datetime import timedelta import logging +from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.knx.const import ( CONF_PAYLOAD_LENGTH, + CONF_VALUE, KNX_ADDRESS, KNX_MODULE_KEY, ) from homeassistant.components.knx.schema import ButtonSchema -from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_TYPE +from homeassistant.const import ( + CONF_NAME, + CONF_PAYLOAD, + CONF_TYPE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import async_capture_events, async_fire_time_changed +from tests.typing import WebSocketGenerator async def test_button_simple( @@ -83,7 +93,7 @@ async def test_button_type(hass: HomeAssistant, knx: KNXTestKit) -> None: ButtonSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", - ButtonSchema.CONF_VALUE: 21.5, + CONF_VALUE: 21.5, CONF_TYPE: "2byte_float", } } @@ -125,7 +135,7 @@ async def test_button_invalid( ButtonSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", - ButtonSchema.CONF_VALUE: conf_value, + CONF_VALUE: conf_value, CONF_TYPE: conf_type, } } @@ -139,3 +149,128 @@ async def test_button_invalid( assert "Setup failed for 'knx': Invalid config." in record.message assert hass.states.get("button.test") is None assert hass.data.get(KNX_MODULE_KEY) is None + + +@pytest.mark.parametrize( + "knx_config", + [ + ( + { + "ga_send": {"write": "1/1/1"}, + "data": {"payload": "1", "payload_length": 1}, # raw payload + } + ), + ( + { + "ga_send": {"write": "1/1/1", "dpt": "5"}, # generic 1byte uint + "data": {"payload": "0x01", "payload_length": 1}, # raw payload + } + ), + ( + { + "ga_send": {"write": "1/1/1", "dpt": "5"}, # generic 1byte uint + "data": {"value": 1}, # typed value + } + ), + ], +) +async def test_button_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_config: dict[str, Any], +) -> None: + """Test creating a button.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.BUTTON, + entity_data={"name": "test"}, + knx_data=knx_config, + ) + await hass.services.async_call( + "button", "press", {"entity_id": "button.test"}, blocking=True + ) + await knx.assert_write("1/1/1", (1,)) + + +async def test_button_ui_load(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test loading a button from storage.""" + await knx.setup_integration(config_store_fixture="config_store_button.json") + + # Raw button configuration + knx.assert_state( + "button.test_raw", + STATE_UNKNOWN, + ) + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_raw"}, blocking=True + ) + await knx.assert_write("1/1/1", (1,)) + + # Typed button configuration + knx.assert_state( + "button.test_typed", + STATE_UNKNOWN, + ) + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_typed"}, blocking=True + ) + await knx.assert_write("1/1/2", True) + + +@pytest.mark.parametrize( + "knx_config", + [ + { # missing data + "ga_send": {"write": "1/1/1", "dpt": "9.001"}, + }, + { # missing DPT + "ga_send": {"write": "1/1/1"}, + "data": {"value": 1}, + }, + { # invalid value for DPT + "ga_send": {"write": "1/1/1", "dpt": "9.001"}, + "data": {"value": "not_valid"}, + }, + { # invalid length for DPT + "ga_send": {"write": "1/1/1", "dpt": "9.001"}, + "data": {"payload": "0x1", "payload_length": 1}, + }, + { # out of bound value for DPT + "ga_send": {"write": "1/1/1", "dpt": "5.001"}, + "data": {"value": 101}, + }, + { # out of bound value for length + "ga_send": {"write": "1/1/1"}, + "data": {"payload": "0x100", "payload_length": 1}, + }, + { # out of bound value for zero-length + "ga_send": {"write": "1/1/1"}, + "data": {"payload": "0x40", "payload_length": 0}, + }, + ], +) +async def test_button_ui_create_data_validation( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + knx_config: dict[str, Any], +) -> None: + """Test creating a button with invalid data.""" + await knx.setup_integration() + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": Platform.BUTTON, + "data": { + "entity": {"name": "test"}, + "knx": knx_config, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + assert res["result"]["error_base"] + assert res["result"]["errors"][0]["path"] diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index dcf9ced2dbba..9598e63b9336 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -35,34 +35,42 @@ 'continuous_dose': None, 'continuous_dose_supported': False, 'doses': dict({ + 'brew_ratio_type': list([ + ]), + 'manual_type': list([ + ]), + 'mass_type': list([ + ]), + 'profile_type': list([ + ]), 'pulses_type': list([ dict({ 'dose': 126.0, 'dose_index': 'DoseA', 'dose_max': 9999.0, 'dose_min': 0.0, - 'dose_step': 1, + 'dose_step': 1.0, }), dict({ 'dose': 126.0, 'dose_index': 'DoseB', 'dose_max': 9999.0, 'dose_min': 0.0, - 'dose_step': 1, + 'dose_step': 1.0, }), dict({ 'dose': 160.0, 'dose_index': 'DoseC', 'dose_max': 9999.0, 'dose_min': 0.0, - 'dose_step': 1, + 'dose_step': 1.0, }), dict({ 'dose': 77.0, 'dose_index': 'DoseD', 'dose_max': 9999.0, 'dose_min': 0.0, - 'dose_step': 1, + 'dose_step': 1.0, }), ]), }), @@ -79,7 +87,7 @@ 'dose_index': 'DoseA', 'dose_max': 90.0, 'dose_min': 0.0, - 'dose_step': 1, + 'dose_step': 1.0, }), ]), 'enabled': True, @@ -347,34 +355,42 @@ 'continuous_dose': None, 'continuous_dose_supported': False, 'doses': dict({ + 'brew_ratio_type': list([ + ]), + 'manual_type': list([ + ]), + 'mass_type': list([ + ]), + 'profile_type': list([ + ]), 'pulses_type': list([ dict({ 'dose': 126.0, 'dose_index': 'DoseA', 'dose_max': 9999.0, 'dose_min': 0.0, - 'dose_step': 1, + 'dose_step': 1.0, }), dict({ 'dose': 126.0, 'dose_index': 'DoseB', 'dose_max': 9999.0, 'dose_min': 0.0, - 'dose_step': 1, + 'dose_step': 1.0, }), dict({ 'dose': 160.0, 'dose_index': 'DoseC', 'dose_max': 9999.0, 'dose_min': 0.0, - 'dose_step': 1, + 'dose_step': 1.0, }), dict({ 'dose': 77.0, 'dose_index': 'DoseD', 'dose_max': 9999.0, 'dose_min': 0.0, - 'dose_step': 1, + 'dose_step': 1.0, }), ]), }), @@ -566,7 +582,7 @@ 'dose_index': 'DoseA', 'dose_max': 90.0, 'dose_min': 0.0, - 'dose_step': 1, + 'dose_step': 1.0, }), ]), 'enabled': True, @@ -593,6 +609,8 @@ 'coffee_station': None, 'connected': True, 'connection_date': '2025-03-21T03:00:19.892000+00:00', + 'eco_mode': None, + 'eco_mode_supported': False, 'image_url': 'https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png', 'location': None, 'model_code': 'LINEAMICRA', @@ -704,7 +722,7 @@ dict({ 'available_update': None, 'build_version': 'v1.17', - 'change_log': 'None', + 'change_log': None, 'status': 'Updated', 'thing_model_code': 'LineaMicra', 'type': 'Machine', @@ -752,7 +770,7 @@ 'Machine': dict({ 'available_update': None, 'build_version': 'v1.17', - 'change_log': 'None', + 'change_log': None, 'status': 'Updated', 'thing_model_code': 'LineaMicra', 'type': 'Machine', diff --git a/tests/components/lawn_mower/test_llm.py b/tests/components/lawn_mower/test_llm.py new file mode 100644 index 000000000000..204b07a4c51a --- /dev/null +++ b/tests/components/lawn_mower/test_llm.py @@ -0,0 +1,59 @@ +"""Tests for the lawn_mower LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.lawn_mower import llm as lawn_mower_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "lawn_mower.test" +INTENTS = {"HassLawnMowerDock", "HassLawnMowerStartMowing"} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a lawn_mower entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "lawn_mower", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test lawn_mower"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the lawn_mower platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed lawn_mower entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no lawn_mower entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) + assert lawn_mower_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert lawn_mower_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/light/test_llm.py b/tests/components/light/test_llm.py new file mode 100644 index 000000000000..d346ce0a4ee4 --- /dev/null +++ b/tests/components/light/test_llm.py @@ -0,0 +1,58 @@ +"""Tests for the light LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.light import llm as light_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "light.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a light entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "light", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test light"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the light platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed light entity.""" + assert "HassLightSet" in await _tool_names(hass) + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no light entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert "HassLightSet" not in await _tool_names(hass) + assert light_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert light_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/llama_cpp/__init__.py b/tests/components/llama_cpp/__init__.py new file mode 100644 index 000000000000..f201820fe927 --- /dev/null +++ b/tests/components/llama_cpp/__init__.py @@ -0,0 +1 @@ +"""Tests for the llama.cpp integration.""" diff --git a/tests/components/llama_cpp/conftest.py b/tests/components/llama_cpp/conftest.py new file mode 100644 index 000000000000..876a1e9bca9d --- /dev/null +++ b/tests/components/llama_cpp/conftest.py @@ -0,0 +1,167 @@ +"""Fixtures for llama.cpp integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass, field +import logging +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.llama_cpp.const import ( + CONF_BASE_URL, + DEFAULT_BASE_URL, + DEFAULT_CONVERSATION_NAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session, llm +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + +CONFIG_ENTRY_DATA = { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: DEFAULT_BASE_URL, +} +ASSIST_OPTIONS = {CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + + +@pytest.fixture(autouse=True) +async def setup_home_assistant(hass: HomeAssistant) -> None: + """Enable dependencies.""" + assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture(name="platforms") +def mock_platforms() -> list[Platform]: + """Fixture for platforms loaded by the integration.""" + return [] + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], +) -> AsyncGenerator[None]: + """Set up the integration.""" + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="config_entry_data") +def config_entry_data_fixture() -> dict[str, Any]: + """Fixture to add data to the config entry.""" + return {} + + +@pytest.fixture(name="config_entry_options") +def config_entry_options_fixture() -> dict[str, Any]: + """Fixture to add options to the config entry.""" + return {} + + +@pytest.fixture(name="mock_config_entry") +def mock_config_entry_fixture( + hass: HomeAssistant, + config_entry_data: dict[str, Any], + config_entry_options: dict[str, Any], +) -> MockConfigEntry: + """Fixture to create a configuration entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="llama.cpp", + data={ + **CONFIG_ENTRY_DATA, + **config_entry_data, + }, + version=1, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data={**config_entry_options}, + subentry_type="conversation", + title=DEFAULT_CONVERSATION_NAME, + unique_id=None, + ), + ], + ) + config_entry.add_to_hass(hass) + return config_entry + + +@dataclass +class MockChatLog(conversation.ChatLog): + """Mock chat log.""" + + _mock_tool_results: dict[str, Any] = field(default_factory=dict) + + def mock_tool_results(self, results: dict[str, Any]) -> None: + """Set tool results.""" + self._mock_tool_results = results + + @property + def llm_api(self) -> llm.APIInstance | None: + """Return LLM API.""" + return self._llm_api + + @llm_api.setter + def llm_api(self, value: llm.APIInstance | None) -> None: + """Set LLM API.""" + self._llm_api = value + + if not value: + return + + async def async_call_tool(tool_input: llm.ToolInput) -> llm.ToolResult: + """Call tool.""" + if tool_input.id not in self._mock_tool_results: + raise ValueError( + f"Tool {tool_input.id} not found ({self._mock_tool_results})" + ) + return self._mock_tool_results[tool_input.id] + + self._llm_api.async_call_tool = async_call_tool + + +@pytest.fixture +def mock_chat_log(hass: HomeAssistant) -> Generator[conversation.ChatLog]: + """Return mock chat logs.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + patch( + "homeassistant.components.conversation.chat_log.ChatLog", + MockChatLog, + ), + chat_session.async_get_chat_session(hass, "mock-conversation-id") as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + yield chat_log + + +@pytest.fixture(autouse=True) +def mock_models_list() -> Generator[AsyncMock]: + """Initialize integration.""" + with patch( + "openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ) as mock_list: + yield mock_list + + +@pytest.fixture(name="mock_completion", autouse=True) +def mock_openai_client_fixture() -> Generator[AsyncMock]: + """Fixture to mock the OpenAI client.""" + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ) as mock_create: + yield mock_create diff --git a/tests/components/llama_cpp/snapshots/test_conversation.ambr b/tests/components/llama_cpp/snapshots/test_conversation.ambr new file mode 100644 index 000000000000..d5853b783cf7 --- /dev/null +++ b/tests/components/llama_cpp/snapshots/test_conversation.ambr @@ -0,0 +1,92 @@ +# serializer version: 1 +# name: test_conversation_entity + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'content': 'Hello, how can I help you?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[config_entry_options0] + list([ + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': False, + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'content': 'I have successfully called the function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_exception[-config_entry_options0] + 'Unexpected tool argument response: Expecting value: line 1 column 1 (char 0)' +# --- +# name: test_function_exception[{"para-config_entry_options0] + 'Unexpected tool argument response: Unterminated string starting at: line 1 column 2 (char 1)' +# --- +# name: test_unknown_hass_api[config_entry_options0] + dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Error preparing LLM API', + }), + }), + }), + }) +# --- diff --git a/tests/components/llama_cpp/test_config_flow.py b/tests/components/llama_cpp/test_config_flow.py new file mode 100644 index 000000000000..dbd0a938ff20 --- /dev/null +++ b/tests/components/llama_cpp/test_config_flow.py @@ -0,0 +1,585 @@ +"""Tests for the llama.cpp config flow.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import openai +import pytest + +from homeassistant import config_entries +from homeassistant.components.llama_cpp.const import ( + CONF_BASE_URL, + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_RECOMMENDED, + CONF_STREAMING, + CONF_TEMPERATURE, + CONF_TOP_P, + DEFAULT_MODEL, + DOMAIN, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import llm + +from tests.common import MockConfigEntry + +RECOMMENDED_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_CHAT_MODEL: DEFAULT_MODEL, +} + + +@pytest.fixture(name="mock_setup") +def mock_setup(hass: HomeAssistant) -> Generator[AsyncMock]: + """Mock the setup of the integration.""" + with patch( + f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_config_flow( + hass: HomeAssistant, + mock_setup: AsyncMock, +) -> None: + """Test selecting a model in the configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "http://localhost:8080/v1" + assert result.get("data") == { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + CONF_STREAMING: True, + } + assert result["options"] == {} + assert result["subentries"] == [ + { + "subentry_type": "conversation", + "data": { + **RECOMMENDED_OPTIONS, + CONF_CHAT_MODEL: "gpt-4", + }, + "title": "Gpt 4", + "unique_id": None, + }, + ] + + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + ( + openai.APIConnectionError(request=httpx.Request(method="POST", url="test")), + "cannot_connect", + ), + ( + openai.AuthenticationError( + message="Invalid key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="POST", url="test"), + ), + body=None, + ), + "invalid_auth", + ), + ( + openai.OpenAIError("Generic error"), + "api_error", + ), + ], +) +async def test_config_flow_fail_completion( + hass: HomeAssistant, + mock_setup: AsyncMock, + mock_completion: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test config flow where the API completion validation fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + mock_completion.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": expected_error} + + assert len(mock_setup.mock_calls) == 0 + + +async def test_config_flow_no_streaming( + hass: HomeAssistant, + mock_setup: AsyncMock, + mock_completion: AsyncMock, +) -> None: + """Test config flow where the API does not support streaming.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + def fail_streaming(stream: bool | None = None, **kwargs: Any) -> None: + """Allow first check to succeed by fail streaming.""" + if stream: + raise openai.OpenAIError("Invalid request") + + mock_completion.side_effect = fail_streaming + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "http://localhost:8080/v1" + assert result.get("data") == { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + CONF_STREAMING: False, + } + assert result["subentries"] == [ + { + "subentry_type": "conversation", + "data": { + **RECOMMENDED_OPTIONS, + CONF_CHAT_MODEL: "gpt-4", + }, + "title": "Gpt 4", + "unique_id": None, + }, + ] + + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert not result["errors"] + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + RECOMMENDED_OPTIONS, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Gpt 3.5 Turbo" + + assert result2["data"] == RECOMMENDED_OPTIONS + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry when entry is not loaded.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + return_value=[], + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry handles connection errors.""" + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.APIConnectionError(request=None), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry_advanced( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry with custom/advanced settings.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Toggle recommended to False to show advanced options + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "Custom instructions", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "init" + + # Now configure the advanced options + result3 = await hass.config_entries.subentries.async_configure( + result2["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "Custom instructions", + CONF_MAX_TOKENS: 500, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Gpt 4" + assert result3["data"] == { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "Custom instructions", + CONF_MAX_TOKENS: 500, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + } + + +async def test_config_flow_model_selection_fallbacks( + hass: HomeAssistant, + mock_setup: AsyncMock, +) -> None: + """Test model selection fallback options through the config flow.""" + # 1. Test empty list fallback (should fallback to DEFAULT_MODEL) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + async def mock_empty_list(*args, **kwargs): + return + yield + + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=mock_empty_list, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "model" + schema = result2["data_schema"].schema + chat_model_key = next(k for k in schema if k == CONF_CHAT_MODEL) + assert chat_model_key.description["suggested_value"] == DEFAULT_MODEL + + # 2. Test no recommended models match fallback (should select first model in the list) + result_custom = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + model1 = MagicMock() + model1.id = "my-custom-model-1" + model2 = MagicMock() + model2.id = "my-custom-model-2" + + async def mock_custom_list(*args, **kwargs): + yield model1 + yield model2 + + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=mock_custom_list, + ): + result3 = await hass.config_entries.flow.async_configure( + result_custom["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "model" + schema = result3["data_schema"].schema + chat_model_key = next(k for k in schema if k == CONF_CHAT_MODEL) + assert chat_model_key.description["suggested_value"] == "my-custom-model-1" + + +async def test_config_flow_connection_errors( + hass: HomeAssistant, + mock_setup: AsyncMock, +) -> None: + """Test config flow handles connection validation errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # 1. Test AuthenticationError + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.AuthenticationError( + message="Invalid Key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="GET", url="test"), + ), + body=None, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # 2. Test APIConnectionError + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.APIConnectionError( + request=httpx.Request(method="GET", url="test") + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result3["type"] is FlowResultType.FORM + assert result3["errors"] == {"base": "cannot_connect"} + + # 3. Test OpenAIError (Generic API errors) + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.OpenAIError("generic error"), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result4["type"] is FlowResultType.FORM + assert result4["errors"] == {"base": "api_error"} + + +@pytest.mark.usefixtures("setup_integration") +async def test_reconfiguring_conversation_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring an existing conversation subentry.""" + subentry = list(mock_config_entry.subentries.values())[0] + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "New prompt", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + updated_subentry = list(mock_config_entry.subentries.values())[0] + assert updated_subentry.title == "Gpt 4" + assert updated_subentry.data[CONF_CHAT_MODEL] == "gpt-4" + assert updated_subentry.data[CONF_PROMPT] == "New prompt" + assert CONF_STREAMING not in updated_subentry.data + + +async def test_subentry_options_entry_not_loaded( + hass: HomeAssistant, + setup_integration: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow aborts if config entry is not loaded.""" + subentry = list(mock_config_entry.subentries.values())[0] + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +async def test_reconfiguring_conversation_subentry_connection_error( + hass: HomeAssistant, + setup_integration: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring subentry aborts if model listing fails.""" + subentry = list(mock_config_entry.subentries.values())[0] + + with patch( + "openai.resources.models.AsyncModels.list", + side_effect=openai.APIConnectionError(request=None), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_reconfiguring_conversation_subentry_validation_error( + hass: HomeAssistant, + setup_integration: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring subentry shows form with error if model validation fails.""" + subentry = list(mock_config_entry.subentries.values())[0] + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + assert result["type"] is FlowResultType.FORM + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=openai.OpenAIError("generic error"), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "New prompt", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "api_error"} + + +async def test_config_flow_unexpected_exception( + hass: HomeAssistant, +) -> None: + """Test user step handles unexpected exception by showing unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.llama_cpp.config_flow.async_create_client", + side_effect=RuntimeError("Unexpected error"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/llama_cpp/test_conversation.py b/tests/components/llama_cpp/test_conversation.py new file mode 100644 index 000000000000..e35445e50616 --- /dev/null +++ b/tests/components/llama_cpp/test_conversation.py @@ -0,0 +1,572 @@ +"""Tests for the llama.cpp conversation platform.""" + +from collections.abc import AsyncGenerator, Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time +import httpx +import openai +from openai.types.chat import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessage, + ChatCompletionMessageToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice, ChoiceDelta +from openai.types.chat.chat_completion_message_tool_call import Function +from openai.types.completion_usage import CompletionUsage +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.components.llama_cpp.const import CONF_STREAMING +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component + +from .conftest import ASSIST_OPTIONS, MockChatLog + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def freeze_the_time() -> Generator[None]: + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.fixture(autouse=True) +def mock_ulid() -> Generator[AsyncMock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.llm.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + +@pytest.fixture(autouse=True) +async def mock_setup_integration_fixture( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Setup the integration.""" + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_conversation_entity( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Verify the conversation entity is loaded.""" + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ): + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + + +@pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test function call from the assistant.""" + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + } + ) + + def completion_result( + *args: Any, messages: list[dict[str, Any]] | list[Any], **kwargs: Any + ) -> ChatCompletion: + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + + +@pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) +@pytest.mark.parametrize( + ("tool_arguments"), + [ + (""), + ('{"para'), + ], +) +async def test_function_exception( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, + tool_arguments: str, + snapshot: SnapshotAssertion, +) -> None: + """Test function call with exception.""" + + def completion_result( + *args: Any, messages: list[dict[str, Any]] | list[Any], **kwargs: Any + ) -> ChatCompletion: + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="There was an error calling the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_AbCdEfGhIjKlMnOpQrStUvWx", + function=Function( + arguments=tool_arguments, + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + "conversation-id", + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.speech["plain"]["speech"] == snapshot + + +@pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) +async def test_assist_api_tools_conversion( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that we are able to convert actual tools from Assist API.""" + for component in ( + "intent", + "todo", + "light", + "shopping_list", + "humidifier", + "climate", + "media_player", + "vacuum", + "cover", + "weather", + ): + assert await async_setup_component(hass, component, {}) + + agent_id = mock_config_entry.entry_id + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ) as mock_create: + await conversation.async_converse(hass, "hello", None, None, agent_id=agent_id) + + tools = mock_create.mock_calls[0][2]["tools"] + assert tools + + +@pytest.mark.parametrize(("config_entry_options"), [{CONF_STREAMING: True}]) +async def test_streaming_response( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, +) -> None: + """Test streaming response from the assistant.""" + + async def mock_stream() -> AsyncGenerator[ChatCompletionChunk]: + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(role="assistant", content="Hello"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(content=" world"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta(), + finish_reason="stop", + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=mock_stream(), + ): + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == "Hello world" + + content = mock_chat_log.content[1:] + assert len(content) == 2 + assert content[0].role == "user" + assert content[0].content == "hello" + assert content[1].role == "assistant" + assert content[1].content == "Hello world" + + +@pytest.mark.parametrize(("config_entry_options"), [{CONF_STREAMING: True}]) +async def test_streaming_response_redundant_role( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, +) -> None: + """Test streaming response where every chunk redundantly includes the role.""" + + async def mock_stream() -> AsyncGenerator[ChatCompletionChunk]: + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(role="assistant", content="Hello"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(role="assistant", content=" world"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta(role="assistant"), + finish_reason="stop", + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=mock_stream(), + ): + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == "Hello world" + + content = mock_chat_log.content[1:] + assert len(content) == 2 + assert content[0].role == "user" + assert content[0].content == "hello" + assert content[1].role == "assistant" + assert content[1].content == "Hello world" + + +@pytest.mark.parametrize( + ("config_entry_options"), [{CONF_LLM_HASS_API: ["non-existing"]}] +) +async def test_unknown_hass_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test when we reference an API that no longer exists.""" + result = await conversation.async_converse( + hass, "hello", "conversation-id", Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.as_dict() == snapshot + + +async def test_conversation_agent_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling of OpenAI API connection errors in conversation entity.""" + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=openai.APIConnectionError( + request=httpx.Request(method="POST", url="test") + ), + ): + result = await conversation.async_converse( + hass, + "hello", + "conversation-id", + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.speech["plain"]["speech"] + == "Cannot connect to the server: Connection error." + ) + + +async def test_conversation_agent_structured_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling of OpenAI API structured errors in conversation entity.""" + response = httpx.Response( + status_code=402, + request=httpx.Request( + method="POST", url="https://api.openai.com/v1/chat/completions" + ), + json={ + "error": { + "message": "Insufficient Balance", + "type": "unknown_error", + "param": None, + "code": "invalid_request_error", + } + }, + ) + err = openai.APIStatusError( + message="Error code: 402 - {'error': {'message': 'Insufficient Balance'}}", + response=response, + body=response.json(), + ) + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=err, + ): + result = await conversation.async_converse( + hass, + "hello", + "conversation-id", + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.speech["plain"]["speech"] + == "Your account or API key has insufficient credits: Insufficient Balance" + ) diff --git a/tests/components/llama_cpp/test_init.py b/tests/components/llama_cpp/test_init.py new file mode 100644 index 000000000000..91136cfa170b --- /dev/null +++ b/tests/components/llama_cpp/test_init.py @@ -0,0 +1,68 @@ +"""Tests for llama.cpp integration setup.""" + +from unittest.mock import AsyncMock, patch + +import httpx +import openai +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting up and unloading llama.cpp entry.""" + with patch( + "openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + ( + openai.AuthenticationError( + message="Invalid API key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="GET", url="test"), + ), + body=None, + ), + ConfigEntryState.SETUP_ERROR, + ), + ( + openai.APIConnectionError(request=None), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_failures( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup entry failure handling.""" + with patch( + "openai.resources.models.AsyncModels.list", + side_effect=side_effect, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state diff --git a/tests/components/llm/__init__.py b/tests/components/llm/__init__.py new file mode 100644 index 000000000000..79662320e496 --- /dev/null +++ b/tests/components/llm/__init__.py @@ -0,0 +1 @@ +"""Tests for the LLM integration.""" diff --git a/tests/components/llm/test_init.py b/tests/components/llm/test_init.py new file mode 100644 index 000000000000..561c2cc04e13 --- /dev/null +++ b/tests/components/llm/test_init.py @@ -0,0 +1,139 @@ +"""Tests for the LLM integration.""" + +from unittest.mock import Mock + +import pytest + +from homeassistant.components.llm import DATA_PLATFORMS, LLMTools, async_get_tools +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component +from homeassistant.util.json import JsonObjectType + +from tests.common import mock_platform + + +class _StubTool(llm.Tool): + """Minimal tool for registry tests.""" + + def __init__(self, name: str) -> None: + """Initialize the stub tool.""" + self.name = name + self.description = f"{name} description" + + async def async_call( + self, + hass: HomeAssistant, + tool_input: llm.ToolInput, + llm_context: llm.LLMContext, + ) -> JsonObjectType: + """Return an empty result.""" + return {} + + +@pytest.fixture +def llm_context() -> llm.LLMContext: + """Return an LLM context.""" + return llm.LLMContext( + platform="test", + context=None, + language="*", + assistant="conversation", + device_id=None, + ) + + +def _mock_tools_platform( + hass: HomeAssistant, domain: str, tools: LLMTools | Exception | None +) -> Mock: + """Register a mock /llm.py platform returning the given tools.""" + if isinstance(tools, Exception): + async_get_tools = Mock(side_effect=tools) + else: + async_get_tools = Mock(return_value=tools) + hass.config.components.add(domain) + mock_platform(hass, f"{domain}.llm", Mock(async_get_tools=async_get_tools)) + return async_get_tools + + +async def test_setup(hass: HomeAssistant) -> None: + """Test the integration sets up.""" + assert await async_setup_component(hass, "llm", {}) + assert DATA_PLATFORMS in hass.data + + +async def test_get_tools(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test that tools from an integration platform are returned.""" + tool = _StubTool("my_tool") + platform_get_tools = _mock_tools_platform( + hass, "test", LLMTools(tools=[tool], prompt="use my_tool wisely") + ) + + assert await async_setup_component(hass, "llm", {}) + + result = await async_get_tools(hass, llm_context, "assist") + # The llm integration also exposes its own GetDateTime tool (domain "llm"). + assert [tool.name for tool in result.tools] == ["GetDateTime", "my_tool"] + assert result.prompt == "use my_tool wisely" + platform_get_tools.assert_called_once_with(hass, llm_context, "assist") + + +async def test_get_tools_empty( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test that only the llm integration's own tools are returned by default.""" + assert await async_setup_component(hass, "llm", {}) + + result = await async_get_tools(hass, llm_context, "assist") + assert [tool.name for tool in result.tools] == ["GetDateTime"] + assert result.prompt is None + + +async def test_get_tools_merges_sorted( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test that tools and prompts are merged in a load-order-independent order.""" + tool_a = _StubTool("tool_a") + tool_b = _StubTool("tool_b") + # Register "test_b" before "test_a" to prove the result is sorted by domain. + _mock_tools_platform(hass, "test_b", LLMTools(tools=[tool_b], prompt="prompt b")) + _mock_tools_platform(hass, "test_a", LLMTools(tools=[tool_a], prompt="prompt a")) + + assert await async_setup_component(hass, "llm", {}) + + result = await async_get_tools(hass, llm_context, "assist") + assert [tool.name for tool in result.tools] == ["GetDateTime", "tool_a", "tool_b"] + assert result.prompt == "prompt a\nprompt b" + + +async def test_get_tools_skips_none_platform( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test that a platform returning None for the API is skipped.""" + tool = _StubTool("good_tool") + _mock_tools_platform(hass, "test_none", None) + _mock_tools_platform(hass, "test_good", LLMTools(tools=[tool])) + + assert await async_setup_component(hass, "llm", {}) + + result = await async_get_tools(hass, llm_context, "assist") + assert [tool.name for tool in result.tools] == ["GetDateTime", "good_tool"] + assert result.prompt is None + + +async def test_get_tools_isolates_failing_platform( + hass: HomeAssistant, + llm_context: llm.LLMContext, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that one failing platform does not drop the others' tools.""" + tool = _StubTool("good_tool") + _mock_tools_platform(hass, "test_bad", ValueError("boom")) + _mock_tools_platform(hass, "test_good", LLMTools(tools=[tool], prompt="prompt")) + + assert await async_setup_component(hass, "llm", {}) + + result = await async_get_tools(hass, llm_context, "assist") + assert [tool.name for tool in result.tools] == ["GetDateTime", "good_tool"] + assert result.prompt == "prompt" + assert "Error getting tools from LLM platform test_bad" in caplog.text diff --git a/tests/components/llm/test_tools.py b/tests/components/llm/test_tools.py new file mode 100644 index 000000000000..cb0d31e31104 --- /dev/null +++ b/tests/components/llm/test_tools.py @@ -0,0 +1,52 @@ +"""Tests for the LLM integration's own tools platform.""" + +from freezegun import freeze_time +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations for the llm tools platform.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "llm", {}) + await hass.config.async_set_time_zone("UTC") + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_get_datetime_tool(hass: HomeAssistant) -> None: + """Test the GetDateTime tool is always offered and returns the current time.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next((tool for tool in result.tools if tool.name == "GetDateTime"), None) + assert tool is not None + + with freeze_time("2025-09-17 13:00:00"): + response = await tool.async_call( + hass, llm.ToolInput("GetDateTime", {}), llm_context + ) + + assert response == { + "success": True, + "result": { + "date": "2025-09-17", + "time": "13:00:00", + "timezone": "UTC", + "weekday": "Wednesday", + }, + } diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 3dc43daaaf70..e25e0d82c9b2 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -9,6 +9,7 @@ import pytest import respx from homeassistant import config_entries +from homeassistant.components.mcp.auth import AuthenticateHeader from homeassistant.components.mcp.const import ( CONF_AUTHORIZATION_URL, CONF_SCOPE, @@ -892,3 +893,135 @@ async def test_reauth_flow( assert token == OAUTH_TOKEN_PAYLOAD assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_reauth_flow_upgrade_to_oauth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test reauth flow upgrading a no-auth entry to OAuth.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: MCP_SERVER_URL}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + auth_header = AuthenticateHeader( + resource_metadata_url="https://example.com/custom-discovery", + scopes=SCOPES_SUPPORTED, + ) + + # Start reauth flow passing auth_header + config_entry.async_start_reauth(hass, data={"auth_header": auth_header}) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + # Mock discovery URLs (bypassing connection validation) + respx.get("https://example.com/custom-discovery").mock( + return_value=OAUTH_PROTECTED_RESOURCE_METADATA_RESPONSE + ) + respx.get(OAUTH_AUTHORIZATION_SERVER_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + # Click Submit on reauth_confirm + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # Flow should proceed to credentials choice + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + authorize_url=OAUTH_AUTHORIZE_URL, + token_url=OAUTH_TOKEN_URL, + scopes=SCOPES_SUPPORTED, + ) + + # Verify we can connect to the server now with the token + response = Mock() + response.serverInfo.name = TEST_API_NAME + # Return success for validation in async_oauth_create_entry + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.unique_id is None + assert config_entry.title == TEST_API_NAME + data = {**config_entry.data} + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + CONF_SCOPE: SCOPES_SUPPORTED, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_reauth_flow_upgrade_to_oauth_no_auth_header( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test reauth flow upgrading a no-auth entry to OAuth when no auth header is passed (fallback).""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: MCP_SERVER_URL}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + # Start reauth flow without passing auth_header + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + # Mock discovery on the default server URL (since there is no auth_header) + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + # Click Submit on reauth_confirm + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # Flow should proceed directly to credentials choice menu (without validate_input) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 0a1f67e13363..70bb2aa22d6c 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -9,9 +9,15 @@ from mcp.types import CallToolResult, ErrorData, ListToolsResult, TextContent, T import pytest import voluptuous as vol +from homeassistant.components.mcp.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import llm from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -84,7 +90,6 @@ async def test_init( [ (httpx.TimeoutException("Some timeout")), (httpx.HTTPStatusError("", request=None, response=httpx.Response(500))), - (httpx.HTTPStatusError("", request=None, response=httpx.Response(401))), (httpx.HTTPError("Some HTTP error")), ], ) @@ -104,6 +109,55 @@ async def test_mcp_server_failure( assert config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_mcp_server_setup_auth_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test setup auth failure triggers reauth.""" + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_mcp_server_setup_auth_failure_with_www_authenticate_header( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test setup auth failure with WWW-Authenticate header parses header and triggers reauth.""" + headers = { + "WWW-Authenticate": 'mcp resource_metadata="https://example.com/custom-discovery", scope="read write"' + } + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", + request=None, + response=httpx.Response(401, headers=headers), + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # Get the flow handler instance and verify it has the correct auth_header + flow_handler = hass.config_entries.flow._progress[flows[0]["flow_id"]] + assert flow_handler.auth_header is not None + assert ( + flow_handler.auth_header.resource_metadata_url + == "https://example.com/custom-discovery" + ) + + async def test_mcp_server_http_transport_failure( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -361,3 +415,293 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert config_entry_with_auth.state is ConfigEntryState.SETUP_RETRY + + +async def test_tool_call_no_auth_auth_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call auth failure when no auth was initially required.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call encountering a 401 response + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + + with pytest.raises(ConfigEntryAuthFailed): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_tool_call_no_auth_auth_failure_with_www_authenticate_header( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call 401 with WWW-Authenticate header triggers reauth and passes header.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call encountering a 401 response with WWW-Authenticate header + headers = { + "WWW-Authenticate": 'mcp resource_metadata="https://example.com/custom-discovery", scope="read write"' + } + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPStatusError( + "Authentication required", + request=None, + response=httpx.Response(401, headers=headers), + ) + + with pytest.raises(ConfigEntryAuthFailed): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # Get the flow handler instance and verify it has the correct auth_header + flow_handler = hass.config_entries.flow._progress[flows[0]["flow_id"]] + assert flow_handler.auth_header is not None + assert ( + flow_handler.auth_header.resource_metadata_url + == "https://example.com/custom-discovery" + ) + + +async def test_tool_call_expired_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call token refresh failure when OAuth is configured.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock token validation failure during tool call + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ), + pytest.raises(ConfigEntryAuthFailed), + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_mcp_server_setup_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, +) -> None: + """Test setup OAuth failure triggers reauth.""" + # Mock token validation failure (e.g. refresh token expired) + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ): + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_list_tools_timeout( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test setup fails with SETUP_RETRY if list tools times out.""" + mock_mcp_client.return_value.list_tools.side_effect = TimeoutError( + "Listing tools timed out" + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_tool_call_timeout( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call timing out raises HomeAssistantError.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call timeout + mock_mcp_client.return_value.call_tool.side_effect = TimeoutError("Call timed out") + + with pytest.raises(HomeAssistantError, match="Timeout when calling tool"): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + +async def test_tool_call_transient_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call transient token refresh failure does not trigger reauth.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock transient token validation failure (e.g. 503 Service Unavailable) + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ), + pytest.raises(HomeAssistantError), + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + # Verify no reauth flow is initiated + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + +async def test_mcp_server_setup_transient_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, +) -> None: + """Test setup transient OAuth failure does not trigger reauth.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ): + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.SETUP_RETRY + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + +async def test_tool_call_http_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call HTTP error raises HomeAssistantError.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call raising HTTPError + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPError( + "Connection timed out or failed" + ) + + with pytest.raises( + HomeAssistantError, + match="Error communicating with MCP server when calling tool", + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index e0004a274e28..d05640414faa 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -26,7 +26,12 @@ from homeassistant.components.mcp_server.http import ( STREAMABLE_API, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON +from homeassistant.const import ( + CONF_LLM_HASS_API, + CONTENT_TYPE_JSON, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -633,3 +638,78 @@ async def test_mcp_tool_call_unicode( response_text = result.content[0].text assert "这是一个测试" in response_text assert "\\u" not in response_text + + +async def test_streamable_api_id_exposes_registered_api( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_supervisor_access_token: str, +) -> None: + """Test the keyed endpoint exposes any registered API, not just the configured one.""" + llm.async_register_api( + hass, MockLLMAPI(hass=hass, id=TEST_LLM_API_ID, name="Test API") + ) + + client = await hass_client() + mcp_url = str(client.make_url(f"{STREAMABLE_API}/{TEST_LLM_API_ID}")) + + async with mcp_streamable_session( + hass, mcp_url, hass_supervisor_access_token + ) as session: + result = await session.list_prompts() + + assert len(result.prompts) == 1 + assert result.prompts[0].name == "Test API" + + +async def test_streamable_api_id_requires_admin( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, +) -> None: + """Test a non-Assist keyed endpoint requires an admin user.""" + llm.async_register_api( + hass, MockLLMAPI(hass=hass, id=TEST_LLM_API_ID, name="Test API") + ) + + client = await hass_client(hass_read_only_access_token) + response = await client.post( + f"{STREAMABLE_API}/{TEST_LLM_API_ID}", + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.UNAUTHORIZED + + +async def test_streamable_api_id_assist_allows_non_admin( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, +) -> None: + """Test the Assist keyed endpoint does not require an admin user.""" + client = await hass_client(hass_read_only_access_token) + response = await client.post( + f"{STREAMABLE_API}/{llm.LLM_API_ASSIST}", + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.OK + + +async def test_streamable_api_id_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test the keyed endpoint returns 404 for an unknown API ID.""" + client = await hass_client() + response = await client.post( + f"{STREAMABLE_API}/does-not-exist", + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.NOT_FOUND + assert "Unknown LLM API" in await response.text() diff --git a/tests/components/media_player/test_llm.py b/tests/components/media_player/test_llm.py new file mode 100644 index 000000000000..7ca247a63af0 --- /dev/null +++ b/tests/components/media_player/test_llm.py @@ -0,0 +1,69 @@ +"""Tests for the media_player LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.media_player import llm as media_player_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "media_player.test" +INTENTS = { + "HassMediaNext", + "HassMediaPause", + "HassMediaPlayerMute", + "HassMediaPlayerUnmute", + "HassMediaPrevious", + "HassMediaSearchAndPlay", + "HassMediaUnpause", + "HassSetVolume", + "HassSetVolumeRelative", +} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a media_player entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "media_player", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test media_player"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the media_player platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed media_player entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no media_player entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) + assert media_player_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert media_player_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/media_source/test_helper.py b/tests/components/media_source/test_helper.py index 824a27f6efb1..03be63acc4bc 100644 --- a/tests/components/media_source/test_helper.py +++ b/tests/components/media_source/test_helper.py @@ -5,8 +5,13 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components import media_source -from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_player import ( + BrowseError, + SearchMedia, + SearchMediaQuery, +) from homeassistant.components.media_source import const, models +from homeassistant.components.media_source.const import MEDIA_SOURCE_DATA from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -125,5 +130,60 @@ async def test_browse_resolve_without_setup() -> None: with pytest.raises(BrowseError): await media_source.async_browse_media(Mock(data={}), None) + with pytest.raises(BrowseError): + await media_source.async_search_media( + Mock(data={}), None, SearchMediaQuery(search_query="test") + ) + with pytest.raises(media_source.Unresolvable): await media_source.async_resolve_media(Mock(data={}), None, None) + + +async def test_async_search_media(hass: HomeAssistant) -> None: + """Test search media helper.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + # Search the default media directory by file name + result = await media_source.async_search_media( + hass, "", SearchMediaQuery(search_query="test") + ) + assert isinstance(result, SearchMedia) + assert [item.title for item in result.result] == ["test.mp3"] + + # A query without matches returns an empty result + result = await media_source.async_search_media( + hass, "", SearchMediaQuery(search_query="no-such-file") + ) + assert result.result == [] + + # Invalid media content raises a BrowseError + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, "invalid", SearchMediaQuery(search_query="test") + ) + + +async def test_async_search_media_not_supported(hass: HomeAssistant) -> None: + """Test searching a source without search support raises a BrowseError.""" + hass.data[MEDIA_SOURCE_DATA] = {"plain": models.MediaSource("plain")} + + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}plain", + SearchMediaQuery(search_query="test"), + ) + + +async def test_async_search_media_root_not_supported(hass: HomeAssistant) -> None: + """Test searching the aggregate root of multiple sources is not supported.""" + hass.data[MEDIA_SOURCE_DATA] = { + "source_a": models.MediaSource("source_a"), + "source_b": models.MediaSource("source_b"), + } + + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, "", SearchMediaQuery(search_query="test") + ) diff --git a/tests/components/media_source/test_http.py b/tests/components/media_source/test_http.py index c5f487f27cfa..c826c7cdd8dd 100644 --- a/tests/components/media_source/test_http.py +++ b/tests/components/media_source/test_http.py @@ -6,7 +6,12 @@ import pytest import yarl from homeassistant.components import media_source -from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_player import ( + BrowseError, + MediaClass, + SearchMedia, + SearchMediaQuery, +) from homeassistant.components.media_source import const from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -127,3 +132,72 @@ async def test_websocket_resolve_media( assert not msg["success"] assert msg["error"]["code"] == "resolve_media_failed" assert msg["error"]["message"] == "test" + + +async def test_websocket_search_media( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test search media websocket.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + search_media = SearchMedia( + result=[ + media_source.models.BrowseMediaSource( + domain=media_source.DOMAIN, + identifier="/media/test.mp3", + title="test.mp3", + media_class=MediaClass.MUSIC, + media_content_type="audio/mpeg", + can_play=True, + can_expand=False, + ) + ] + ) + + with patch( + "homeassistant.components.media_source.http.async_search_media", + return_value=search_media, + ) as mock_search: + await client.send_json( + { + "id": 1, + "type": "media_source/search_media", + "media_content_id": f"{const.URI_SCHEME}{media_source.DOMAIN}", + "search_query": "test", + "media_filter_classes": ["music"], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["id"] == 1 + assert msg["result"] == search_media.as_dict() + + # The query is built from the websocket message, coercing the filter classes + query = mock_search.call_args[0][2] + assert isinstance(query, SearchMediaQuery) + assert query.search_query == "test" + assert query.media_filter_classes == [MediaClass.MUSIC] + + with patch( + "homeassistant.components.media_source.http.async_search_media", + side_effect=BrowseError("test"), + ): + await client.send_json( + { + "id": 2, + "type": "media_source/search_media", + "media_content_id": "invalid", + "search_query": "test", + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "search_media_failed" + assert msg["error"]["message"] == "test" diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index cf63ad9d6884..47a7b030de79 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -10,8 +10,13 @@ from unittest.mock import patch import pytest from homeassistant.components import media_source, websocket_api -from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_player import ( + BrowseError, + MediaClass, + SearchMediaQuery, +) from homeassistant.components.media_source import const +from homeassistant.components.media_source.local_source import MAX_SEARCH_RESULTS from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component @@ -90,6 +95,123 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: assert media +async def test_async_search_media(hass: HomeAssistant) -> None: + """Test searching local media.""" + local_media = hass.config.path("media") + await async_process_ha_core_config( + hass, {"media_dirs": {"local": local_media, "recordings": local_media}} + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + # Search within a single directory (contextual) + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local", + SearchMediaQuery(search_query="test"), + ) + assert [item.title for item in result.result] == ["test.mp3"] + assert ( + result.result[0].media_content_id + == f"{const.URI_SCHEME}{const.DOMAIN}/local/test.mp3" + ) + + # Search across all directories (global) finds the file in both dirs + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}", + SearchMediaQuery(search_query="sax"), + ) + assert {item.title for item in result.result} == {"Epic Sax Guy 10 Hours.mp4"} + assert len(result.result) == 2 + + # Non-media files are not returned + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local", + SearchMediaQuery(search_query="not_media"), + ) + assert result.result == [] + + # Searching a non-existent directory returns no results + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local/nonexistent", + SearchMediaQuery(search_query="test"), + ) + assert result.result == [] + + # Filter by media class + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local", + SearchMediaQuery(search_query="", media_filter_classes=[MediaClass.MUSIC]), + ) + assert [item.title for item in result.result] == ["test.mp3"] + + # Invalid path raises a BrowseError + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local/../secret", + SearchMediaQuery(search_query="test"), + ) + + +async def test_async_search_media_limit_and_hidden( + hass: HomeAssistant, tmp_path: Path +) -> None: + """Test that search caps results and skips hidden files.""" + for i in range(MAX_SEARCH_RESULTS + 20): + (tmp_path / f"song_{i}.mp3").touch() + (tmp_path / ".hidden_song.mp3").touch() + + await async_process_ha_core_config( + hass, {"media_dirs": {"local": str(tmp_path), "recordings": str(tmp_path)}} + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + # Global search across both dirs; the first dir already fills the limit + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}", + SearchMediaQuery(search_query="song"), + ) + assert len(result.result) == MAX_SEARCH_RESULTS + assert all(not item.title.startswith(".") for item in result.result) + + +async def test_browse_media_can_search(hass: HomeAssistant) -> None: + """Test that browsable directories advertise search support.""" + local_media = hass.config.path("media") + await async_process_ha_core_config( + hass, {"media_dirs": {"local": local_media, "recordings": local_media}} + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + # The root of multiple directories is searchable + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{const.DOMAIN}" + ) + assert media.can_search + + # A directory is searchable, but the files inside it are not + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/." + ) + assert media.can_search + file_child = next(child for child in media.children if child.can_play) + assert not file_child.can_search + + async def test_media_view( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: diff --git a/tests/components/media_source/test_models.py b/tests/components/media_source/test_models.py index 1ed03a839615..ad4662c4d2b5 100644 --- a/tests/components/media_source/test_models.py +++ b/tests/components/media_source/test_models.py @@ -1,6 +1,12 @@ """Test Media Source model methods.""" -from homeassistant.components.media_player import MediaClass, MediaType +import pytest + +from homeassistant.components.media_player import ( + MediaClass, + MediaType, + SearchMediaQuery, +) from homeassistant.components.media_source import const, models from homeassistant.core import HomeAssistant @@ -84,3 +90,11 @@ async def test_media_source_item_media_source_id(hass: HomeAssistant) -> None: # Test with no domain (root) item = models.MediaSourceItem(hass, None, "", None) assert item.media_source_id == "media-source://" + + +async def test_media_source_search_media_not_implemented(hass: HomeAssistant) -> None: + """Test the base MediaSource.async_search_media raises NotImplementedError.""" + source = models.MediaSource(const.DOMAIN) + item = models.MediaSourceItem(hass, const.DOMAIN, "", None) + with pytest.raises(NotImplementedError): + await source.async_search_media(item, SearchMediaQuery(search_query="test")) diff --git a/tests/components/mikrotik/conftest.py b/tests/components/mikrotik/conftest.py new file mode 100644 index 000000000000..2152786deb6f --- /dev/null +++ b/tests/components/mikrotik/conftest.py @@ -0,0 +1,15 @@ +"""Config tests Mikrotik.""" + +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def mock_api(): + """Mock api.""" + with ( + patch("librouteros.create_transport"), + patch("librouteros.Api.readResponse") as mock_api, + ): + yield mock_api diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 972454803008..722a243794a8 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -1,9 +1,8 @@ """Test Mikrotik setup process.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from librouteros.exceptions import ConnectionClosed, LibRouterosError -import pytest from homeassistant.components import mikrotik from homeassistant.config_entries import ConfigEntryState @@ -14,16 +13,6 @@ from . import MOCK_DATA from tests.common import MockConfigEntry -@pytest.fixture(autouse=True) -def mock_api(): - """Mock api.""" - with ( - patch("librouteros.create_transport"), - patch("librouteros.Api.readResponse") as mock_api, - ): - yield mock_api - - async def test_successful_config_entry(hass: HomeAssistant) -> None: """Test config entry successful setup.""" entry = MockConfigEntry( diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index 6279f3ff429a..76abd3269cd0 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -58,7 +58,7 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: with pytest.raises(BrowseError) as excinfo: await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/") assert str(excinfo.value) == ( - "Failed to browse media with content id media-source://netatmo/: " + "Failed to browse media with content ID media-source://netatmo/: " "Invalid media source URI" ) # Test successful listing diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr index e87db0a25c0d..a0b8cb4ff9cf 100644 --- a/tests/components/nextcloud/snapshots/test_config_flow.ambr +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -7,6 +7,14 @@ 'verify_ssl': True, }) # --- +# name: test_reconfigure_entry + dict({ + 'password': 'other_password', + 'url': 'https://my.nc_url.local', + 'username': 'other_user', + 'verify_ssl': True, + }) +# --- # name: test_user_create_entry dict({ 'password': 'nc_pass', diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index 16b6bf3bc046..211d2168909b 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -32,7 +32,7 @@ async def test_user_create_entry( DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {} # test NextcloudMonitorAuthorizationError @@ -46,7 +46,7 @@ async def test_user_create_entry( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {"base": "invalid_auth"} # test NextcloudMonitorConnectionError @@ -60,7 +60,7 @@ async def test_user_create_entry( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {"base": "connection_error"} # test NextcloudMonitorRequestError @@ -74,7 +74,7 @@ async def test_user_create_entry( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {"base": "connection_error"} # test success @@ -93,6 +93,86 @@ async def test_user_create_entry( assert result["data"] == snapshot +async def test_reconfigure_entry( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test that the reconfigure step works.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="https://my.nc_url.local", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {} + + # test NextcloudMonitorAuthorizationError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorAuthorizationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {"base": "invalid_auth"} + + # test NextcloudMonitorConnectionError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorConnectionError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {"base": "connection_error"} + + # test NextcloudMonitorRequestError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorRequestError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {"base": "connection_error"} + + # test success + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + **VALID_CONFIG, + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == snapshot + + async def test_user_already_configured(hass: HomeAssistant) -> None: """Test that errors are shown when duplicates are added.""" entry = MockConfigEntry( @@ -107,7 +187,7 @@ async def test_user_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {} with patch( diff --git a/tests/components/overkiz/conftest.py b/tests/components/overkiz/conftest.py index e3ae83f9e6be..56430839b895 100644 --- a/tests/components/overkiz/conftest.py +++ b/tests/components/overkiz/conftest.py @@ -136,6 +136,22 @@ def mock_rexel_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_rexel_local_config_entry() -> MockConfigEntry: + """Return a Rexel config entry set up via the Local API.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={ + "host": "gateway-1234-5678-9123.local:8443", + "token": "1234123412341234", + "verify_ssl": True, + "hub": "rexel", + "api_type": "local", + }, + ) + + @pytest.fixture def mock_client() -> MockOverkizClient: """Return a configurable mock Overkiz client.""" diff --git a/tests/components/overkiz/test_init.py b/tests/components/overkiz/test_init.py index 42217c84a394..3f47b09e9764 100644 --- a/tests/components/overkiz/test_init.py +++ b/tests/components/overkiz/test_init.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import ( from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from .conftest import MockOverkizClient from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_PASSWORD, TEST_SERVER from tests.common import MockConfigEntry, RegistryEntryWithDefaults, mock_registry @@ -112,6 +113,36 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: assert mock_entry.minor_version == 2 +async def test_setup_rexel_local_uses_local_client( + hass: HomeAssistant, + mock_rexel_local_config_entry: MockConfigEntry, + mock_client: MockOverkizClient, +) -> None: + """A Rexel gateway configured via the Local API must not use OAuth2.""" + mock_rexel_local_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.overkiz.create_local_client", + return_value=mock_client, + ) as mock_create_local_client, + patch( + "homeassistant.components.overkiz.create_rexel_client" + ) as mock_create_rexel_client, + ): + await hass.config_entries.async_setup(mock_rexel_local_config_entry.entry_id) + await hass.async_block_till_done() + + mock_create_local_client.assert_called_once_with( + hass, + host="gateway-1234-5678-9123.local:8443", + token="1234123412341234", + verify_ssl=True, + ) + mock_create_rexel_client.assert_not_called() + assert mock_rexel_local_config_entry.state is ConfigEntryState.LOADED + + async def test_setup_token_reauth_error_starts_reauth( hass: HomeAssistant, mock_rexel_config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index a8df4bf188c0..258ecd49fcab 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -557,3 +557,23 @@ async def test_reload_migration_with_leading_zero_mac( len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 1 ) + + +async def test_options_listener( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test options update listener reloading the config entry.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + # Changing options triggers reload + with patch( + "homeassistant.components.rainbird.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + hass.config_entries.async_update_entry(config_entry, options={"duration": 5}) + await hass.async_block_till_done() + + # The entry should have been reloaded + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index df99522f9dde..3352250630cf 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -111,6 +111,23 @@ async def test_search( wled_segment_2_entity.entity_id, area_id=bedroom_area.id ) + # Config entry with a device providing a scene entity + esphome_config_entry = MockConfigEntry(domain="esphome") + esphome_config_entry.add_to_hass(hass) + esphome_device = device_registry.async_get_or_create( + config_entry_id=esphome_config_entry.entry_id, + name="Node", + identifiers={("esphome", "esphome-1")}, + ) + esphome_scene_entity = entity_registry.async_get_or_create( + "scene", + "esphome", + "esphome-1-scene", + suggested_object_id="esphome scene", + config_entry=esphome_config_entry, + device_id=esphome_device.id, + ) + scene_wled_hue_entity = entity_registry.async_get_or_create( "scene", "homeassistant", @@ -136,6 +153,23 @@ async def test_search( labels={label_other.label_id}, ) + # Device tracker of the person, provided by a config entry with a device + mobile_app_config_entry = MockConfigEntry(domain="mobile_app") + mobile_app_config_entry.add_to_hass(hass) + mobile_app_device = device_registry.async_get_or_create( + config_entry_id=mobile_app_config_entry.entry_id, + name="Paulus iPhone", + identifiers={("mobile_app", "phone-1")}, + ) + entity_registry.async_get_or_create( + "device_tracker", + "mobile_app", + "phone-1-tracker", + suggested_object_id="paulus_iphone", + config_entry=mobile_app_config_entry, + device_id=mobile_app_device.id, + ) + script_scene_entity = entity_registry.async_get_or_create( "script", "script", @@ -658,6 +692,18 @@ async def test_search( ItemType.SCENE: {"scene.scene_hue_seg_1", scene_wled_hue_entity.entity_id}, ItemType.SCRIPT: {"script.device", "script.hue"}, } + assert search(ItemType.DEVICE, esphome_device.id) == { + ItemType.CONFIG_ENTRY: {esphome_config_entry.entry_id}, + ItemType.ENTITY: {esphome_scene_entity.entity_id}, + ItemType.INTEGRATION: {"esphome"}, + ItemType.SCENE: {esphome_scene_entity.entity_id}, + } + assert search(ItemType.CONFIG_ENTRY, esphome_config_entry.entry_id) == { + ItemType.DEVICE: {esphome_device.id}, + ItemType.ENTITY: {esphome_scene_entity.entity_id}, + ItemType.INTEGRATION: {"esphome"}, + ItemType.SCENE: {esphome_scene_entity.entity_id}, + } assert not search(ItemType.ENTITY, "sensor.unknown") assert search(ItemType.ENTITY, wled_segment_1_entity.entity_id) == { @@ -725,6 +771,9 @@ async def test_search( ItemType.SCRIPT: {script_scene_entity.entity_id}, } assert search(ItemType.ENTITY, "device_tracker.paulus_iphone") == { + ItemType.CONFIG_ENTRY: {mobile_app_config_entry.entry_id}, + ItemType.DEVICE: {mobile_app_device.id}, + ItemType.INTEGRATION: {"mobile_app"}, ItemType.PERSON: {person_paulus_entity.entity_id}, } assert search(ItemType.ENTITY, "light.wled_config_entry_source") == { @@ -819,11 +868,20 @@ async def test_search( assert not search(ItemType.LABEL, "unknown") assert search(ItemType.LABEL, label_christmas.label_id) == { + ItemType.AREA: {living_room_area.id}, ItemType.AUTOMATION: {"automation.label"}, + ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id}, ItemType.DEVICE: {wled_device.id}, + ItemType.FLOOR: {first_floor.floor_id}, + ItemType.INTEGRATION: {"wled"}, } assert search(ItemType.LABEL, label_energy.label_id) == { + ItemType.AREA: {kitchen_area.id}, + ItemType.CONFIG_ENTRY: {hue_config_entry.entry_id}, + ItemType.DEVICE: {hue_device.id}, ItemType.ENTITY: {hue_segment_1_entity.entity_id}, + ItemType.FLOOR: {first_floor.floor_id}, + ItemType.INTEGRATION: {"hue"}, } assert search(ItemType.LABEL, label_other.label_id) == { ItemType.AREA: {bedroom_area.id}, @@ -832,6 +890,7 @@ async def test_search( person_paulus_entity.entity_id, script_scene_entity.entity_id, }, + ItemType.FLOOR: {second_floor.floor_id}, ItemType.PERSON: {person_paulus_entity.entity_id}, ItemType.SCENE: {scene_wled_hue_entity.entity_id}, ItemType.SCRIPT: {"script.label", script_scene_entity.entity_id}, @@ -840,8 +899,11 @@ async def test_search( assert not search(ItemType.PERSON, "person.unknown") assert search(ItemType.PERSON, person_paulus_entity.entity_id) == { ItemType.AREA: {bedroom_area.id}, + ItemType.CONFIG_ENTRY: {mobile_app_config_entry.entry_id}, + ItemType.DEVICE: {mobile_app_device.id}, ItemType.ENTITY: {"device_tracker.paulus_iphone"}, ItemType.FLOOR: {second_floor.floor_id}, + ItemType.INTEGRATION: {"mobile_app"}, ItemType.LABEL: {label_other.label_id}, } diff --git a/tests/components/signal_messenger/conftest.py b/tests/components/signal_messenger/conftest.py index 1c9c60c28783..db6af1b0dda6 100644 --- a/tests/components/signal_messenger/conftest.py +++ b/tests/components/signal_messenger/conftest.py @@ -1,6 +1,7 @@ """Signal notification test helpers.""" from http import HTTPStatus +from unittest.mock import patch from pysignalclirestapi import SignalCliRestApi import pytest @@ -14,7 +15,8 @@ MESSAGE = "Testing Signal Messenger platform :)" CONTENT = b"TestContent" NUMBER_FROM = "+43443434343" NUMBERS_TO = ["+435565656565"] -URL_ATTACHMENT = "http://127.0.0.1:8080/image.jpg" +SIGNAL_BASE_URL = "http://127.0.0.1:8080" +URL_ATTACHMENT = f"{SIGNAL_BASE_URL}/image.jpg" @pytest.fixture @@ -23,7 +25,8 @@ def signal_notification_service(hass: HomeAssistant) -> SignalNotificationServic hass.config.allowlist_external_urls.add(URL_ATTACHMENT) recipients = ["+435565656565"] number = "+43443434343" - client = SignalCliRestApi("http://127.0.0.1:8080", number) + with patch.object(SignalCliRestApi, "mode", return_value="normal"): + client = SignalCliRestApi(SIGNAL_BASE_URL, number) return SignalNotificationService(hass, recipients, client) @@ -36,21 +39,23 @@ def signal_requests_mock_factory(requests_mock: Mocker) -> Mocker: ) -> Mocker: requests_mock.register_uri( "GET", - "http://127.0.0.1:8080/v1/about", + f"{SIGNAL_BASE_URL}/v1/about", status_code=HTTPStatus.OK, json={"versions": ["v1", "v2"]}, ) if success_send_result: requests_mock.register_uri( "POST", - "http://127.0.0.1:8080" + SIGNAL_SEND_PATH_SUFIX, + SIGNAL_BASE_URL + SIGNAL_SEND_PATH_SUFIX, status_code=HTTPStatus.CREATED, + json={"timestamp": "1"}, ) else: requests_mock.register_uri( "POST", - "http://127.0.0.1:8080" + SIGNAL_SEND_PATH_SUFIX, + SIGNAL_BASE_URL + SIGNAL_SEND_PATH_SUFIX, status_code=HTTPStatus.BAD_REQUEST, + json={"error": "Couldn't send message"}, ) if content_length_header is not None: requests_mock.register_uri( diff --git a/tests/components/signal_messenger/test_notify.py b/tests/components/signal_messenger/test_notify.py index 87b9d5b6f9c6..e9946978302e 100644 --- a/tests/components/signal_messenger/test_notify.py +++ b/tests/components/signal_messenger/test_notify.py @@ -21,6 +21,7 @@ from .conftest import ( MESSAGE, NUMBER_FROM, NUMBERS_TO, + SIGNAL_BASE_URL, SIGNAL_SEND_PATH_SUFIX, URL_ATTACHMENT, SignalNotificationService, @@ -33,13 +34,16 @@ async def test_signal_messenger_init(hass: HomeAssistant) -> None: NOTIFY_DOMAIN: { "name": "test", "platform": "signal_messenger", - "url": "http://127.0.0.1:8080", + "url": SIGNAL_BASE_URL, "number": NUMBER_FROM, "recipients": NUMBERS_TO, } } - with patch("pysignalclirestapi.SignalCliRestApi.send_message", return_value=None): + with ( + patch("pysignalclirestapi.SignalCliRestApi.send_message", return_value=None), + patch("pysignalclirestapi.SignalCliRestApi.mode", return_value="normal"), + ): assert await async_setup_component(hass, NOTIFY_DOMAIN, config) await hass.async_block_till_done() @@ -59,7 +63,7 @@ def test_send_message( signal_notification_service.send_message(MESSAGE) assert "Sending signal message" in caplog.text assert signal_requests_mock.called - assert signal_requests_mock.call_count == 2 + assert signal_requests_mock.call_count == 3 assert_sending_requests(signal_requests_mock) @@ -78,7 +82,7 @@ def test_send_message_with_custom_recipients( ) assert "Sending signal message" in caplog.text assert signal_requests_mock.called - assert signal_requests_mock.call_count == 2 + assert signal_requests_mock.call_count == 3 assert_sending_requests( signal_requests_mock, recipients=["+49111111111", "+49222222222"] ) @@ -99,7 +103,7 @@ def test_send_message_styled( post_data = json.loads(signal_requests_mock.request_history[-1].text) assert "Sending signal message" in caplog.text assert signal_requests_mock.called - assert signal_requests_mock.call_count == 2 + assert signal_requests_mock.call_count == 3 assert post_data["text_mode"] == "styled" assert_sending_requests(signal_requests_mock) @@ -121,8 +125,8 @@ def test_send_message_to_api_with_bad_data_throws_error( assert "Sending signal message" in caplog.text assert signal_requests_mock.called - assert signal_requests_mock.call_count == 2 - assert "Couldn't send signal message" in str(exc.value) + assert signal_requests_mock.call_count == 3 + assert "send message" in str(exc.value).lower() def test_send_message_with_bad_data_throws_vol_error( @@ -185,7 +189,7 @@ def test_send_message_with_attachment( assert "Sending signal message" in caplog.text assert signal_requests_mock.called - assert signal_requests_mock.call_count == 2 + assert signal_requests_mock.call_count == 3 assert_sending_requests(signal_requests_mock, 1) @@ -210,7 +214,7 @@ def test_send_message_styled_with_attachment( post_data = json.loads(signal_requests_mock.request_history[-1].text) assert "Sending signal message" in caplog.text assert signal_requests_mock.called - assert signal_requests_mock.call_count == 2 + assert signal_requests_mock.call_count == 3 assert_sending_requests(signal_requests_mock, 1) assert post_data["text_mode"] == "styled" @@ -230,7 +234,7 @@ def test_send_message_with_attachment_as_url( assert "Sending signal message" in caplog.text assert signal_requests_mock.called - assert signal_requests_mock.call_count == 3 + assert signal_requests_mock.call_count == 4 assert_sending_requests(signal_requests_mock, 1) @@ -249,7 +253,7 @@ def test_send_message_styled_with_attachment_as_url( post_data = json.loads(signal_requests_mock.request_history[-1].text) assert "Sending signal message" in caplog.text assert signal_requests_mock.called - assert signal_requests_mock.call_count == 3 + assert signal_requests_mock.call_count == 4 assert_sending_requests(signal_requests_mock, 1) assert post_data["text_mode"] == "styled" @@ -448,8 +452,8 @@ def assert_sending_requests( assert body_request["message"] == MESSAGE assert body_request["number"] == NUMBER_FROM assert body_request["recipients"] == (recipients or NUMBERS_TO) - assert len(body_request["base64_attachments"]) == attachments_num + assert len(body_request.get("base64_attachments", [])) == attachments_num - for attachment in body_request["base64_attachments"]: + for attachment in body_request.get("base64_attachments", []): if len(attachment) > 0: assert base64.b64decode(attachment) == CONTENT diff --git a/tests/components/switchbot/test_fan.py b/tests/components/switchbot/test_fan.py index f70899c37c7d..b39cba0b788a 100644 --- a/tests/components/switchbot/test_fan.py +++ b/tests/components/switchbot/test_fan.py @@ -26,6 +26,7 @@ from . import ( AIR_PURIFIER_TABLE_US_SERVICE_INFO, AIR_PURIFIER_US_SERVICE_INFO, CIRCULATOR_FAN_SERVICE_INFO, + STANDING_FAN_SERVICE_INFO, ) from tests.common import MockConfigEntry @@ -232,3 +233,146 @@ async def test_exception_handling_air_purifier_service( {**service_data, ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("service", "service_data", "mock_method", "expected_call"), + [ + ( + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: "sleep"}, + "set_preset_mode", + ("sleep",), + ), + (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 50}, "set_percentage", (50,)), + (SERVICE_OSCILLATE, {ATTR_OSCILLATING: True}, "set_oscillation", (True,)), + (SERVICE_TURN_OFF, {}, "turn_off", ()), + (SERVICE_TURN_ON, {}, "turn_on", ()), + ], +) +async def test_standing_fan_controlling( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_call: tuple, +) -> None: + """Test controlling the standing fan with different services.""" + inject_bluetooth_service_info(hass, STANDING_FAN_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="standing_fan") + entity_id = "fan.test_name" + entry.add_to_hass(hass) + + mocked_instance = AsyncMock(return_value=True) + mocked_none = AsyncMock(return_value=None) + with patch.multiple( + "homeassistant.components.switchbot.fan.switchbot.SwitchbotStandingFan", + get_basic_info=mocked_none, + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + FAN_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once_with(*expected_call) + + +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: "sleep"}, "set_preset_mode"), + (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 50}, "set_percentage"), + (SERVICE_OSCILLATE, {ATTR_OSCILLATING: True}, "set_oscillation"), + (SERVICE_TURN_OFF, {}, "turn_off"), + (SERVICE_TURN_ON, {}, "turn_on"), + ], +) +async def test_exception_handling_standing_fan_service( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, +) -> None: + """Test a communication error raises HomeAssistantError for the standing fan.""" + inject_bluetooth_service_info(hass, STANDING_FAN_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="standing_fan") + entry.add_to_hass(hass) + entity_id = "fan.test_name" + + mocked_none = AsyncMock(return_value=None) + with patch.multiple( + "homeassistant.components.switchbot.fan.switchbot.SwitchbotStandingFan", + get_basic_info=mocked_none, + **{ + mock_method: AsyncMock( + side_effect=SwitchbotOperationError("Operation failed") + ) + }, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises( + HomeAssistantError, + match="An error occurred while performing the action: Operation failed", + ): + await hass.services.async_call( + FAN_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: "sleep"}, "set_preset_mode"), + (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 50}, "set_percentage"), + (SERVICE_OSCILLATE, {ATTR_OSCILLATING: True}, "set_oscillation"), + (SERVICE_TURN_OFF, {}, "turn_off"), + (SERVICE_TURN_ON, {}, "turn_on"), + ], +) +async def test_standing_fan_command_failure( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, +) -> None: + """Test an unsuccessful command (device returns False) raises HomeAssistantError.""" + inject_bluetooth_service_info(hass, STANDING_FAN_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="standing_fan") + entry.add_to_hass(hass) + entity_id = "fan.test_name" + + mocked_none = AsyncMock(return_value=None) + with patch.multiple( + "homeassistant.components.switchbot.fan.switchbot.SwitchbotStandingFan", + get_basic_info=mocked_none, + **{mock_method: AsyncMock(return_value=False)}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises( + HomeAssistantError, match="Failed to send the command to the fan" + ): + await hass.services.async_call( + FAN_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/tesla_fleet/test_switch.py b/tests/components/tesla_fleet/test_switch.py index dcdf66b7cc18..ac418721cb21 100644 --- a/tests/components/tesla_fleet/test_switch.py +++ b/tests/components/tesla_fleet/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from tesla_fleet_api.const import AutoSeat from tesla_fleet_api.exceptions import VehicleOffline from homeassistant.components.switch import ( @@ -150,6 +151,27 @@ async def test_switch_services( call.assert_called_once() +async def test_switch_auto_seat_climate_off_seat_position( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that turning off auto seat climate sends the 1-indexed AutoSeat position.""" + + await setup_platform(hass, normal_config_entry, [Platform.SWITCH]) + + with patch( + "tesla_fleet_api.tesla.VehicleFleet.remote_auto_seat_climate_request", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_auto_seat_climate_left"}, + blocking=True, + ) + call.assert_called_once_with(AutoSeat.FRONT_LEFT, False) + + async def test_switch_no_scope( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index a7e58d525ba2..7c2ec7a34f6d 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -16,7 +16,7 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION, ) from homeassistant.components.teslemetry.coordinator import ENERGY_INFO_INTERVAL -from homeassistant.components.teslemetry.select import LOW +from homeassistant.components.teslemetry.select import LEVEL, LOW, MEDIUM, OFF from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -183,6 +183,68 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: call.assert_called_once() +@pytest.mark.parametrize( + ("entity_id", "seat_position"), + [ + ("select.test_seat_cooler_front_left", 1), + ("select.test_seat_cooler_front_right", 2), + ], +) +async def test_seat_cooler_services( + hass: HomeAssistant, + mock_metadata: AsyncMock, + mock_vehicle_data: AsyncMock, + entity_id: str, + seat_position: int, +) -> None: + """Test the seat cooler entities send the 1-indexed seat position. + + remote_seat_cooler_request is 1-indexed (front-left=1, front-right=2), + unlike the 0-indexed Seat enum used for the seat heaters. + """ + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + metadata = deepcopy(METADATA) + metadata["vehicles"][VEHICLE_VIN]["config"] = {"has_seat_cooling": True} + mock_metadata.return_value = metadata + + await setup_platform(hass, [Platform.SELECT]) + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.remote_seat_cooler_request", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LOW}, + blocking=True, + ) + assert hass.states.get(entity_id).state == LOW + call.assert_called_once_with(seat_position, LEVEL[LOW]) + + +async def test_seat_cooler_polling( + hass: HomeAssistant, + mock_metadata: AsyncMock, + mock_vehicle_data: AsyncMock, +) -> None: + """Test the seat cooler entities read polled state from seat_fan_front_*.""" + metadata = deepcopy(METADATA) + metadata["vehicles"][VEHICLE_VIN]["polling"] = True + metadata["vehicles"][VEHICLE_VIN]["config"] = {"has_seat_cooling": True} + mock_metadata.return_value = metadata + + data = deepcopy(VEHICLE_DATA_ALT) + data["response"]["climate_state"]["seat_fan_front_left"] = 2 + data["response"]["climate_state"]["seat_fan_front_right"] = 0 + mock_vehicle_data.return_value = data + + await setup_platform(hass, [Platform.SELECT]) + + assert hass.states.get("select.test_seat_cooler_front_left").state == MEDIUM + assert hass.states.get("select.test_seat_cooler_front_right").state == OFF + + @pytest.mark.parametrize("response", COMMAND_ERRORS) async def test_select_command_errors( hass: HomeAssistant, mock_vehicle_data: AsyncMock, response: dict diff --git a/tests/components/todo/test_llm.py b/tests/components/todo/test_llm.py new file mode 100644 index 000000000000..c409e0e0288b --- /dev/null +++ b/tests/components/todo/test_llm.py @@ -0,0 +1,127 @@ +"""Tests for the todo LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component, todo +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.todo import llm as todo_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import config_validation as cv, llm +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service + +ENTITY_ID = "todo.test_list" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a to-do list.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "todo", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "0", {"friendly_name": "Mock Todo List Name"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_get_tools_no_exposed_todo(hass: HomeAssistant) -> None: + """Test no todo tool is offered when no to-do list is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "todo_get_items" not in [tool.name for tool in result.tools] + assert todo_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert todo_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_todo_get_items_tool(hass: HomeAssistant) -> None: + """Test the todo get items tool is exposed and works via the platform.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next((tool for tool in result.tools if tool.name == "todo_get_items"), None) + assert tool is not None + assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] + + calls = async_mock_service( + hass, + domain=todo.DOMAIN, + service=todo.TodoServices.GET_ITEMS, + schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), + response={ + ENTITY_ID: { + "items": [ + {"uid": "1234", "summary": "Buy milk", "status": "needs_action"}, + ] + } + }, + ) + + result = await tool.async_call( + hass, + llm.ToolInput("todo_get_items", {"todo_list": "Mock Todo List Name"}), + llm_context, + ) + + assert len(calls) == 1 + assert calls[0].data == {"entity_id": [ENTITY_ID], "status": ["needs_action"]} + assert result == { + "success": True, + "result": [{"uid": "1234", "status": "needs_action", "summary": "Buy milk"}], + } + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + ("all", ["needs_action", "completed"]), + ("completed", ["completed"]), + ], +) +async def test_todo_get_items_status_filter( + hass: HomeAssistant, status: str, expected: list[str] +) -> None: + """Test the status filter is translated into the service call.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next(tool for tool in result.tools if tool.name == "todo_get_items") + + calls = async_mock_service( + hass, + domain=todo.DOMAIN, + service=todo.TodoServices.GET_ITEMS, + schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), + response={ENTITY_ID: {"items": []}}, + ) + await tool.async_call( + hass, + llm.ToolInput( + "todo_get_items", {"todo_list": "Mock Todo List Name", "status": status} + ), + llm_context, + ) + assert calls[0].data == {"entity_id": [ENTITY_ID], "status": expected} + + +async def test_todo_list_intents_exposed(hass: HomeAssistant) -> None: + """Test the todo list intents are exposed as tools when a list is exposed.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + names = {tool.name for tool in result.tools} + assert "HassListAddItem" in names + assert "HassListCompleteItem" in names + assert "HassListRemoveItem" in names diff --git a/tests/components/v2c/fixtures/get_data.json b/tests/components/v2c/fixtures/get_data.json index 2c082cefb746..c559d6639eba 100644 --- a/tests/components/v2c/fixtures/get_data.json +++ b/tests/components/v2c/fixtures/get_data.json @@ -5,6 +5,7 @@ "ChargePower": 1500.27, "VoltageInstallation": 230, "ChargeEnergy": 1.8, + "ChargeMode": 1, "SlaveError": 4, "ChargeTime": 4355, "HousePower": 0.0, diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 0f1d2cd09766..847366bf21a3 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -22,8 +22,8 @@ 'unique_id': 'ABC123', 'version': 1, }), - 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=230, charge_energy=1.8, charge_mode=None, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", + 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=230, charge_energy=1.8, charge_mode=, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", 'host_status': 200, - 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"VoltageInstallation":230,"ChargeEnergy":1.8,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"LightLED":25,"LogoLED":75,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', + 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"VoltageInstallation":230,"ChargeEnergy":1.8,"ChargeMode":1,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"LightLED":25,"LogoLED":75,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', }) # --- diff --git a/tests/components/v2c/snapshots/test_select.ambr b/tests/components/v2c/snapshots/test_select.ambr new file mode 100644 index 000000000000..8e0e93135fdd --- /dev/null +++ b/tests/components/v2c/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_select[select.evse_1_1_1_1_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'monophasic', + 'threephasic', + 'mixed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.evse_1_1_1_1_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charge mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge mode', + 'platform': 'v2c', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_charge_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.evse_1_1_1_1_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'EVSE 1.1.1.1 Charge mode', + : list([ + 'monophasic', + 'threephasic', + 'mixed', + ]), + }), + 'context': , + 'entity_id': 'select.evse_1_1_1_1_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'threephasic', + }) +# --- diff --git a/tests/components/v2c/test_select.py b/tests/components/v2c/test_select.py new file mode 100644 index 000000000000..4c3ca5b6fffd --- /dev/null +++ b/tests/components/v2c/test_select.py @@ -0,0 +1,72 @@ +"""Test the V2C select platform.""" + +from unittest.mock import AsyncMock, patch + +from pytrydan.models.trydan import ChargeMode +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_select( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the select entities.""" + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.SELECT]): + await init_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_option( + hass: HomeAssistant, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test selecting an option.""" + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.SELECT]): + await init_integration(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.evse_1_1_1_1_charge_mode", + ATTR_OPTION: "mixed", + }, + blocking=True, + ) + + mock_v2c_client.charge_mode.assert_awaited_once_with(ChargeMode.MIXED) + + +async def test_select_not_created_when_missing( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test missing charge mode entity is not created.""" + mock_v2c_client.get_data.return_value.charge_mode = None + + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.SELECT]): + await init_integration(hass, mock_config_entry) + + entity_id = "select.evse_1_1_1_1_charge_mode" + assert entity_registry.async_get(entity_id) is None + assert hass.states.get(entity_id) is None diff --git a/tests/components/vacuum/test_llm.py b/tests/components/vacuum/test_llm.py new file mode 100644 index 000000000000..441b1d8eb6df --- /dev/null +++ b/tests/components/vacuum/test_llm.py @@ -0,0 +1,59 @@ +"""Tests for the vacuum LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.vacuum import llm as vacuum_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "vacuum.test" +INTENTS = {"HassVacuumCleanArea", "HassVacuumReturnToBase", "HassVacuumStart"} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a vacuum entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "vacuum", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test vacuum"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the vacuum platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed vacuum entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no vacuum entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) + assert vacuum_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert vacuum_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/wattwaechter/snapshots/test_diagnostics.ambr b/tests/components/wattwaechter/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..c293927a0baf --- /dev/null +++ b/tests/components/wattwaechter/snapshots/test_diagnostics.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'device_id': 'ABC123', + 'fw_version': '1.2.3', + 'host': '192.168.1.100', + 'mac': '**REDACTED**', + 'model': 'WW-Plus', + 'token': '**REDACTED**', + }), + 'meter': dict({ + 'datetime_str': '2024-01-01T00:00:00', + 'timestamp': 1704067200, + 'values': dict({ + '1.8.0': dict({ + 'name': 'Total Import', + 'unit': 'kWh', + 'value': 12345.678, + }), + '13.7.0': dict({ + 'name': 'Power Factor', + 'unit': '', + 'value': 0.985, + }), + '14.7.0': dict({ + 'name': 'Frequency', + 'unit': 'Hz', + 'value': 50.01, + }), + '16.7.0': dict({ + 'name': 'Active Power', + 'unit': 'W', + 'value': 1500.5, + }), + '2.8.0': dict({ + 'name': 'Total Export', + 'unit': 'kWh', + 'value': 1234.567, + }), + '31.7.0': dict({ + 'name': 'Current L1', + 'unit': 'A', + 'value': 6.52, + }), + '32.7.0': dict({ + 'name': 'Voltage L1', + 'unit': 'V', + 'value': 230.1, + }), + }), + }), + 'system': dict({ + 'ap': dict({ + }), + 'esp': dict({ + 'esp_id': 'ABC123', + 'os_version': '1.2.3', + }), + 'heap': dict({ + 'free_heap': '120000', + }), + 'uptime': dict({ + 'uptime': '2d 5h 30m', + }), + 'wifi': dict({ + 'ip_address': '192.168.1.100', + 'mac_address': '**REDACTED**', + 'mdns_name': '**REDACTED**', + 'signal_strength': '-45', + 'ssid': '**REDACTED**', + }), + }), + }) +# --- diff --git a/tests/components/wattwaechter/test_diagnostics.py b/tests/components/wattwaechter/test_diagnostics.py new file mode 100644 index 000000000000..6c67343b6a8d --- /dev/null +++ b/tests/components/wattwaechter/test_diagnostics.py @@ -0,0 +1,48 @@ +"""Tests for the WattWächter Plus diagnostics.""" + +from unittest.mock import AsyncMock + +from aio_wattwaechter import WattwaechterConnectionError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +async def test_diagnostics_system_info_unavailable( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, +) -> None: + """Test diagnostics still return config and meter data without system info.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_client.system_info.side_effect = WattwaechterConnectionError("offline") + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result["system"] is None + assert result["meter"] is not None diff --git a/tests/components/wattwaechter/test_sensor.py b/tests/components/wattwaechter/test_sensor.py index 60103cd52748..d20615393038 100644 --- a/tests/components/wattwaechter/test_sensor.py +++ b/tests/components/wattwaechter/test_sensor.py @@ -2,17 +2,20 @@ from __future__ import annotations +from datetime import timedelta from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion -from homeassistant.components.wattwaechter.const import DOMAIN +from homeassistant.components.wattwaechter.const import DEFAULT_SCAN_INTERVAL, DOMAIN +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .conftest import MOCK_DEVICE_ID, MOCK_METER_DATA_MINIMAL -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_all_entities( @@ -54,3 +57,29 @@ async def test_minimal_meter_data( assert _get_entity_id("2.8.0") is None assert _get_entity_id("32.7.0") is None assert _get_entity_id("31.7.0") is None + + +async def test_sensor_value_unknown_when_obis_stops_reporting( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a sensor reports unknown when its OBIS code is no longer reported.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{MOCK_DEVICE_ID}_2.8.0" + ) + assert entity_id is not None + assert hass.states.get(entity_id).state != STATE_UNKNOWN + + # Device stops reporting the export total OBIS code + mock_client.meter_data.return_value = MOCK_METER_DATA_MINIMAL + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN diff --git a/tests/helpers/test_http.py b/tests/helpers/test_http.py new file mode 100644 index 000000000000..81bcfc18bd89 --- /dev/null +++ b/tests/helpers/test_http.py @@ -0,0 +1,40 @@ +"""Tests for the HTTP helpers.""" + +from http import HTTPStatus + +from aiohttp import web +import pytest + +from homeassistant.helpers.http import MIN_COMPRESSED_RESPONSE_SIZE, HomeAssistantView + +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("body_size", "expect_compression"), + [ + pytest.param(8, False, id="small-body-not-compressed"), + pytest.param( + MIN_COMPRESSED_RESPONSE_SIZE * 2, True, id="large-body-compressed" + ), + ], +) +@pytest.mark.usefixtures("socket_enabled") +async def test_json_response_compression_threshold( + aiohttp_client: ClientSessionGenerator, + body_size: int, + expect_compression: bool, +) -> None: + """Test HomeAssistantView.json only compresses bodies above the threshold.""" + + async def handler(request: web.Request) -> web.Response: + return HomeAssistantView.json({"data": "x" * body_size}) + + app = web.Application() + app.router.add_get("/", handler) + client = await aiohttp_client(app) + + resp = await client.get("/", headers={"Accept-Encoding": "gzip, deflate"}) + + assert resp.status == HTTPStatus.OK + assert ("Content-Encoding" in resp.headers) is expect_compression diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 8f2a978a14eb..bf363d695600 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -38,7 +38,7 @@ def llm_context() -> llm.LLMContext: platform="", context=None, language=None, - assistant=None, + assistant="conversation", device_id=None, ) @@ -367,8 +367,6 @@ async def test_assist_api_tools( assert [tool.name for tool in api.tools] == [ "HassTurnOn", "HassTurnOff", - "HassSetPosition", - "HassStopMoving", "HassStartTimer", "HassCancelTimer", "HassCancelAllTimers",