diff --git a/.strict-typing b/.strict-typing index bba2767899010..8b1f044aee5e7 100644 --- a/.strict-typing +++ b/.strict-typing @@ -109,6 +109,7 @@ homeassistant.components.auth.* homeassistant.components.automation.* homeassistant.components.awair.* homeassistant.components.axis.* +homeassistant.components.axle_energy.* homeassistant.components.azure_storage.* homeassistant.components.backblaze_b2.* homeassistant.components.backup.* diff --git a/CODEOWNERS b/CODEOWNERS index 9d7731e81de7d..b1bef3c792446 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -206,6 +206,8 @@ CLAUDE.md @home-assistant/core /tests/components/aws_s3/ @tomasbedrich /homeassistant/components/axis/ @Kane610 /tests/components/axis/ @Kane610 +/homeassistant/components/axle_energy/ @Herbertmt978 +/tests/components/axle_energy/ @Herbertmt978 /homeassistant/components/azure_data_explorer/ @kaareseras /tests/components/azure_data_explorer/ @kaareseras /homeassistant/components/azure_devops/ @timmo001 diff --git a/homeassistant/components/axle_energy/__init__.py b/homeassistant/components/axle_energy/__init__.py new file mode 100644 index 0000000000000..766bb47d293f1 --- /dev/null +++ b/homeassistant/components/axle_energy/__init__.py @@ -0,0 +1,26 @@ +"""The Axle Energy integration.""" + +from aioaxlevpp import AxleClient + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import PLATFORMS +from .coordinator import AxleConfigEntry, AxleCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: AxleConfigEntry) -> bool: + """Set up Axle Energy from a config entry.""" + coordinator = AxleCoordinator( + hass, entry, AxleClient(async_get_clientsession(hass), entry.data[CONF_API_KEY]) + ) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AxleConfigEntry) -> bool: + """Unload Axle Energy.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/axle_energy/config_flow.py b/homeassistant/components/axle_energy/config_flow.py new file mode 100644 index 0000000000000..2d2772f385afd --- /dev/null +++ b/homeassistant/components/axle_energy/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for Axle Energy.""" + +from typing import Any, override + +from aioaxlevpp import AxleAuthenticationError, AxleClient, AxleError +import probatio + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +STEP_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ) + } +) + + +class AxleConfigFlow(ConfigFlow, domain=DOMAIN): + """Configure the household's Axle event feed.""" + + async def _validate(self, user_input: dict[str, Any]) -> dict[str, str]: + """Check a token with the service, including when no event is scheduled.""" + client = AxleClient( + async_get_clientsession(self.hass), user_input[CONF_API_KEY] + ) + try: + await client.get_event() + except AxleAuthenticationError: + return {"base": "invalid_auth"} + except AxleError: + return {"base": "cannot_connect"} + return {} + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure the household event feed.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]}) + if not (errors := await self._validate(user_input)): + return self.async_create_entry(title="Axle Energy", data=user_input) + return self.async_show_form( + step_id="user", + data_schema=STEP_SCHEMA, + description_placeholders={ + "token_url": "https://vpp.axle.energy/app/account/home-assistant" + }, + errors=errors, + ) diff --git a/homeassistant/components/axle_energy/const.py b/homeassistant/components/axle_energy/const.py new file mode 100644 index 0000000000000..5d48f78da3dd2 --- /dev/null +++ b/homeassistant/components/axle_energy/const.py @@ -0,0 +1,9 @@ +"""Constants for Axle Energy.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "axle_energy" +PLATFORMS = [Platform.SENSOR] +UPDATE_INTERVAL = timedelta(minutes=10) diff --git a/homeassistant/components/axle_energy/coordinator.py b/homeassistant/components/axle_energy/coordinator.py new file mode 100644 index 0000000000000..23093eaa82e0a --- /dev/null +++ b/homeassistant/components/axle_energy/coordinator.py @@ -0,0 +1,51 @@ +"""Coordinate Axle event updates.""" + +import logging +from typing import override + +from aioaxlevpp import AxleAuthenticationError, AxleClient, AxleError, GridEvent + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) +type AxleConfigEntry = ConfigEntry[AxleCoordinator] + + +class AxleCoordinator(DataUpdateCoordinator[GridEvent | None]): + """Fetch one event using the provider's documented polling interval.""" + + config_entry: AxleConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: AxleConfigEntry, client: AxleClient + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + always_update=False, + ) + self.client = client + + @override + async def _async_update_data(self) -> GridEvent | None: + """Fetch the event without confusing outages with an empty schedule.""" + try: + event = await self.client.get_event() + except AxleAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except AxleError as err: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + return None if event is not None and event.opted_out else event diff --git a/homeassistant/components/axle_energy/entity.py b/homeassistant/components/axle_energy/entity.py new file mode 100644 index 0000000000000..a922d199d74f0 --- /dev/null +++ b/homeassistant/components/axle_energy/entity.py @@ -0,0 +1,23 @@ +"""Shared entity for the Axle event feed.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AxleCoordinator + + +class AxleEntity(CoordinatorEntity[AxleCoordinator]): + """An entity belonging to one Axle event feed.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AxleCoordinator) -> None: + """Initialize the shared service identity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name="Axle Energy", + manufacturer="Axle Energy", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/axle_energy/manifest.json b/homeassistant/components/axle_energy/manifest.json new file mode 100644 index 0000000000000..0f180300da7f7 --- /dev/null +++ b/homeassistant/components/axle_energy/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "axle_energy", + "name": "Axle Energy", + "codeowners": ["@Herbertmt978"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/axle_energy", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["aioaxlevpp==0.1.0"] +} diff --git a/homeassistant/components/axle_energy/quality_scale.yaml b/homeassistant/components/axle_energy/quality_scale.yaml new file mode 100644 index 0000000000000..d6a4944363734 --- /dev/null +++ b/homeassistant/components/axle_energy/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + action-setup: + status: exempt + comment: This integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide custom actions. + docs-triggers: + status: exempt + comment: This integration does not provide custom triggers. + docs-conditions: + status: exempt + comment: This integration does not provide custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + action-exceptions: + status: exempt + comment: This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + devices: done + diagnostics: todo + discovery: + status: exempt + comment: The household event feed is a cloud service with no local endpoint. + discovery-update-info: + status: exempt + comment: The household event feed is a cloud service with no local endpoint. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The API exposes one household event feed, not a list of devices. + entity-category: + status: exempt + comment: All entities provide primary event information. + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: The API exposes one household event feed, not a list of devices. + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/axle_energy/sensor.py b/homeassistant/components/axle_energy/sensor.py new file mode 100644 index 0000000000000..df9b8a4bc7e7a --- /dev/null +++ b/homeassistant/components/axle_energy/sensor.py @@ -0,0 +1,84 @@ +"""Sensor platform for Axle Energy.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import override + +from aioaxlevpp import GridEvent + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import AxleConfigEntry, AxleCoordinator +from .entity import AxleEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AxleSensorDescription(SensorEntityDescription): + """Describe one field in a grid event.""" + + value_fn: Callable[[GridEvent], str | datetime] + + +SENSORS = ( + AxleSensorDescription( + key="import_export", + translation_key="import_export", + device_class=SensorDeviceClass.ENUM, + options=["import", "export"], + value_fn=lambda event: event.direction, + ), + AxleSensorDescription( + key="start", + translation_key="start", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda event: event.start, + ), + AxleSensorDescription( + key="end", + translation_key="end", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda event: event.end, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AxleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up event metadata sensors.""" + async_add_entities( + AxleSensor(entry.runtime_data, description) for description in SENSORS + ) + + +class AxleSensor(AxleEntity, SensorEntity): + """Represent a field in the current grid event.""" + + entity_description: AxleSensorDescription + + def __init__( + self, coordinator: AxleCoordinator, description: AxleSensorDescription + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + + @property + @override + def native_value(self) -> str | datetime | None: + """Return unknown when a healthy feed has no participating event.""" + if (event := self.coordinator.data) is None: + return None + return self.entity_description.value_fn(event) diff --git a/homeassistant/components/axle_energy/strings.json b/homeassistant/components/axle_energy/strings.json new file mode 100644 index 0000000000000..79e5dd84542d8 --- /dev/null +++ b/homeassistant/components/axle_energy/strings.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The Home Assistant token for your Axle account." + }, + "description": "Open the [Home Assistant page in your Axle account]({token_url}) and select **Generate Token**. Enter the generated token in **API key**." + } + } + }, + "entity": { + "sensor": { + "end": { + "name": "Event end" + }, + "import_export": { + "name": "Event type", + "state": { + "export": "Export", + "import": "Import" + } + }, + "start": { + "name": "Event start" + } + } + }, + "exceptions": { + "authentication_failed": { + "message": "Authentication failed. Check your Axle API key." + }, + "cannot_connect": { + "message": "Unable to retrieve grid events from Axle." + } + } +} diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index 9512db0db4942..5c29c5bf137c1 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -2,10 +2,11 @@ from typing import TYPE_CHECKING, Any, override -from boschshcpy import SHCShutterControl, ShutterControlService +from boschshcpy import SHCMicromoduleBlinds, SHCShutterControl, ShutterControlService from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, @@ -38,7 +39,19 @@ async def async_setup_entry( parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, ) - for cover in session.device_helper.shutter_controls + for cover in ( + *session.device_helper.shutter_controls, + *session.device_helper.micromodule_shutter_controls, + ) + ) + async_add_entities( + BlindsControlCover( + hass=hass, + device=blind, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + ) + for blind in session.device_helper.micromodule_blinds ) @@ -46,7 +59,6 @@ class ShutterControlCover(SHCEntity, CoverEntity): """Representation of a SHC shutter control device.""" _attr_name = None - _attr_device_class = CoverDeviceClass.SHUTTER _device: SHCShutterControl _attr_supported_features = ( CoverEntityFeature.OPEN @@ -55,6 +67,14 @@ class ShutterControlCover(SHCEntity, CoverEntity): | CoverEntityFeature.SET_POSITION ) + @property + @override + def device_class(self) -> CoverDeviceClass: + """Return the device class.""" + if self._device.device_model == "MICROMODULE_AWNING": + return CoverDeviceClass.AWNING + return CoverDeviceClass.SHUTTER + @property @override def current_cover_position(self) -> int: @@ -99,3 +119,51 @@ def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] self._device.level = position / 100.0 + + +class BlindsControlCover(ShutterControlCover): + """Representation of a SHC micromodule blinds cover device.""" + + _device: SHCMicromoduleBlinds + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + @property + @override + def device_class(self) -> CoverDeviceClass: + """Return the device class.""" + return CoverDeviceClass.BLIND + + @override + def stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + self._device.stop_blinds() + + @property + @override + def current_cover_tilt_position(self) -> int: + """Return the current cover tilt position.""" + return round((1.0 - self._device.current_angle) * 100.0) + + @override + def open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + self._device.target_angle = 0.0 + + @override + def close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + self._device.target_angle = 1.0 + + @override + def set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + tilt_position = kwargs[ATTR_TILT_POSITION] + self._device.target_angle = 1.0 - (tilt_position / 100.0) diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 114decd4880bc..a5974a0b15278 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -6,6 +6,7 @@ from boschshcpy import ( SHCLightSwitchBSM, + SHCMicromoduleShutterControl, SHCSmartPlug, SHCSmartPlugCompact, SHCThermostat, @@ -47,7 +48,7 @@ class SHCSensorEntityDescription[_DeviceT: SHCDevice](SensorEntityDescription): attributes_fn: Callable[[_DeviceT], dict[str, Any]] | None = None -_PowerMeterDevice = SHCSmartPlug | SHCLightSwitchBSM +_PowerMeterDevice = SHCSmartPlug | SHCLightSwitchBSM | SHCMicromoduleShutterControl TEMPERATURE_SENSOR = "temperature" HUMIDITY_SENSOR = "humidity" @@ -263,6 +264,8 @@ async def async_setup_entry( power_meter_devices: list[_PowerMeterDevice] = [ *session.device_helper.smart_plugs, *session.device_helper.light_switches_bsm, + *session.device_helper.micromodule_shutter_controls, + *session.device_helper.micromodule_blinds, ] entities.extend( SHCSensor( diff --git a/homeassistant/components/collection_image/config_flow.py b/homeassistant/components/collection_image/config_flow.py index e0f66c65248cb..a1394e52ed044 100644 --- a/homeassistant/components/collection_image/config_flow.py +++ b/homeassistant/components/collection_image/config_flow.py @@ -8,6 +8,8 @@ from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.components.media_source import URI_SCHEME, async_browse_media from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import MediaSelector from .const import CONF_MEDIA, DOMAIN @@ -23,57 +25,102 @@ ) +async def _async_validate_media( + hass: HomeAssistant, + user_input: dict[str, Any], +) -> tuple[str | None, dict[str, str], dict[str, str]]: + """Validate selected directories and return title and form errors.""" + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + found_pictures = False + title = "Unnamed collection" + + for user_media in user_input[CONF_MEDIA]: + if user_media["media_content_id"] == IMAGE_MEDIA_URI: + errors[CONF_MEDIA] = "invalid_selection" + placeholders["error"] = IMAGE_MEDIA_URI + break + + try: + browse = await async_browse_media( + hass, + user_media["media_content_id"], + ) + except BrowseError as err: + errors[CONF_MEDIA] = "failed_browse" + placeholders["error"] = str(err) + break + + if ( + not found_pictures + and browse.children + and any(item.media_class == MediaClass.IMAGE for item in browse.children) + ): + found_pictures = True + if browse.title: + title = f"{browse.title} collection" + + if not errors and not found_pictures: + errors[CONF_MEDIA] = "selected_media_no_images" + + return (title if not errors else None), errors, placeholders + + class CollectionImageConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Collection Image.""" + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + entry = self._get_reconfigure_entry() + if user_input is not None: + title, errors, placeholders = await _async_validate_media( + self.hass, + user_input, + ) + if title is not None: + return self.async_update_reload_and_abort( + entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + user_input or {CONF_MEDIA: cv.ensure_list(entry.data[CONF_MEDIA])}, + ), + errors=errors, + description_placeholders=placeholders, + ) + @override async def async_step_user( - self, user_input: dict[str, Any] | None = None + self, + user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle initial setup.""" errors: dict[str, str] = {} placeholders: dict[str, str] = {} - found_pictures = False - title = "Unnamed collection" + if user_input is not None: - user_media_list = user_input[CONF_MEDIA] - for user_media in user_media_list: - if user_media["media_content_id"] == IMAGE_MEDIA_URI: - errors["media"] = "invalid_selection" - placeholders["error"] = IMAGE_MEDIA_URI - break - try: - browse = await async_browse_media( - self.hass, user_media["media_content_id"] - ) - except BrowseError as err: - errors["media"] = "failed_browse" - placeholders["error"] = str(err) - break - else: - if ( - not found_pictures - and browse.children - and any( - item.media_class == MediaClass.IMAGE - for item in browse.children - ) - ): - found_pictures = True - if browse.title: - title = f"{browse.title} collection" - if "media" not in errors: - if found_pictures: - return self.async_create_entry( - title=title, - data=user_input, - ) - errors["media"] = "selected_media_no_images" + title, errors, placeholders = await _async_validate_media( + self.hass, + user_input, + ) + if title is not None: + return self.async_create_entry( + title=title, + data=user_input, + ) return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - STEP_USER_DATA_SCHEMA, user_input + STEP_USER_DATA_SCHEMA, + user_input, ), errors=errors, description_placeholders=placeholders, diff --git a/homeassistant/components/collection_image/quality_scale.yaml b/homeassistant/components/collection_image/quality_scale.yaml index 8d1ca9c64efc5..e4963d9a45a42 100644 --- a/homeassistant/components/collection_image/quality_scale.yaml +++ b/homeassistant/components/collection_image/quality_scale.yaml @@ -92,9 +92,7 @@ rules: icon-translations: status: exempt comment: No meaningful icon translations for an image entity. - reconfiguration-flow: - status: exempt - comment: Nothing to reconfigure. + reconfiguration-flow: done repair-issues: status: exempt comment: Nothing to repair. diff --git a/homeassistant/components/collection_image/strings.json b/homeassistant/components/collection_image/strings.json index df62a7351561c..2106c05a7011a 100644 --- a/homeassistant/components/collection_image/strings.json +++ b/homeassistant/components/collection_image/strings.json @@ -6,6 +6,15 @@ "selected_media_no_images": "The selected media has no images. Please select a media directory with images." }, "step": { + "reconfigure": { + "data": { + "media": "[%key:component::collection_image::config::step::user::data::media%]" + }, + "data_description": { + "media": "[%key:component::collection_image::config::step::user::data_description::media%]" + }, + "description": "Updates the source media used for the collection." + }, "user": { "data": { "media": "Media" diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index 0a15c01438e27..5409909707879 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.17.0"], + "requirements": ["python-duco-connectivity==0.18.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index 9e34771094388..a20cb0fda40d7 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -102,9 +102,7 @@ async def post(self, request: web.Request) -> web.Response | None: _LOGGER.debug("Received Foursquare push: %s", data) if self.push_secret != secret: - _LOGGER.error( - "Received Foursquare push with invalid push secret: %s", secret - ) + _LOGGER.error("Received Foursquare push with an invalid push secret") return self.json_message("Incorrect secret", HTTPStatus.BAD_REQUEST) request.app[KEY_HASS].bus.async_fire(EVENT_PUSH, data) diff --git a/homeassistant/components/hotspring/diagnostics.py b/homeassistant/components/hotspring/diagnostics.py index 889fe04fded9f..44e3b7235212b 100644 --- a/homeassistant/components/hotspring/diagnostics.py +++ b/homeassistant/components/hotspring/diagnostics.py @@ -41,15 +41,17 @@ async def async_get_config_entry_diagnostics( "data": { "info": info, "heater": asdict(spa.heater), - "jets": [asdict(jet) for jet in spa.jets], + "jets": [asdict(jet) for jet in spa.jets.values()], "blower": asdict(spa.blower), - "light_zones": [asdict(zone) for zone in spa.light_zones], + "light_zones": [asdict(zone) for zone in spa.light_zones.values()], "logo_light": asdict(spa.logo_light), "clean_cycle": asdict(spa.clean_cycle), "spa_lock": asdict(spa.spa_lock), "water_care": asdict(spa.water_care), "freshwater_iq": asdict(spa.freshwater_iq), - "energy_savings": [asdict(schedule) for schedule in spa.energy_savings], + "energy_savings": [ + asdict(schedule) for schedule in spa.energy_savings.values() + ], "versions": asdict(spa.versions), "connection_status": asdict(spa.connection_status), "diagnostics": asdict(spa.diagnostics), diff --git a/homeassistant/components/hotspring/light.py b/homeassistant/components/hotspring/light.py index 83b371be48321..96e5060874b9e 100644 --- a/homeassistant/components/hotspring/light.py +++ b/homeassistant/components/hotspring/light.py @@ -40,7 +40,7 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( HotSpringLightEntity(coordinator, zone.zone_id) - for zone in coordinator.data.light_zones + for zone in coordinator.data.light_zones.values() if zone.is_enabled ) @@ -65,10 +65,7 @@ def __init__( @property def _zone(self) -> LightZone: """Return the light zone data.""" - for zone in self.coordinator.data.light_zones: - if zone.zone_id == self._zone_id: - return zone - raise AssertionError("Light zone must exist in coordinator data") + return self.coordinator.data.light_zones[self._zone_id] @property @override diff --git a/homeassistant/components/hotspring/manifest.json b/homeassistant/components/hotspring/manifest.json index 3b577f5df72d9..3bdb7992571b4 100644 --- a/homeassistant/components/hotspring/manifest.json +++ b/homeassistant/components/hotspring/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["hotspring"], "quality_scale": "platinum", - "requirements": ["python-hotspring==2.1.0"], + "requirements": ["python-hotspring==3.0.0"], "zeroconf": [ { "name": "watkins_spa*", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 7440972a65233..2835631c2fc90 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -10,7 +10,7 @@ import probatio from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.auth.providers.homeassistant import HassAuthProvider +from homeassistant.auth.providers.homeassistant import HassAuthProvider, InvalidUsername from homeassistant.components import person from homeassistant.components.auth import indieauth from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID @@ -199,10 +199,18 @@ async def post(self, request: web.Request, data: dict[str, str]) -> web.Response provider = _async_get_hass_provider(hass) await provider.async_initialize() + # Add the auth before creating the user, as it validates the + # username, to avoid leaving an orphaned user behind on failure. + try: + await provider.async_add_auth(data["username"], data["password"]) + except InvalidUsername as err: + return self.json_message( + str(err), HTTPStatus.BAD_REQUEST, err.translation_key + ) + user = await hass.auth.async_create_user( data["name"], group_ids=[GROUP_ID_ADMIN] ) - await provider.async_add_auth(data["username"], data["password"]) credentials = await provider.async_get_or_create_credentials( {"username": data["username"]} ) diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index ad61827594b9c..d06a2f8b00cfd 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -1,5 +1,6 @@ """Support for controlling projector via the PJLink protocol.""" +from collections.abc import Callable from typing import Any, override import probatio @@ -16,6 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -188,27 +190,32 @@ def update(self) -> None: else: raise + def _run_command(self, command: Callable[[Projector], None]) -> None: + """Run a projector command and surface failures as HomeAssistantError.""" + try: + with self.projector() as projector: + command(projector) + except (ProjectorError, TimeoutError, OSError) as err: + # script helpers honor continue_on_error only for HomeAssistantError + raise HomeAssistantError(f"PJLink command failed: {err}") from err + @override def turn_off(self) -> None: """Turn projector off.""" - with self.projector() as projector: - projector.set_power("off") + self._run_command(lambda projector: projector.set_power("off")) @override def turn_on(self) -> None: """Turn projector on.""" - with self.projector() as projector: - projector.set_power("on") + self._run_command(lambda projector: projector.set_power("on")) @override def mute_volume(self, mute: bool) -> None: """Mute (true) of unmute (false) media player.""" - with self.projector() as projector: - projector.set_mute(MUTE_AUDIO, mute) + self._run_command(lambda projector: projector.set_mute(MUTE_AUDIO, mute)) @override def select_source(self, source: str) -> None: """Set the input source.""" - source = self._source_name_mapping[source] - with self.projector() as projector: - projector.set_input(*source) + selected = self._source_name_mapping[source] + self._run_command(lambda projector: projector.set_input(*selected)) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index bc04a8abb97e0..f0ebf06acf59e 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.16", + "PlexAPI==4.18.2", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index 8bd1b9f018495..437d4eac0577c 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -195,7 +195,7 @@ def build_item_response(payload): "children": [], "children_media_class": children_media_class, } - for item in hub.items: + for item in hub.items(): if hub.type == "station": if platform == "sonos": continue diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 28f58a2a9948e..d502a27c93cb2 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -167,6 +167,18 @@ def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self._attr_current_option + @final + @callback + def _options_or_raise(self) -> list[str]: + """Return the options, raise ServiceValidationError if there are none.""" + if not (options := self.options): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_options", + translation_placeholders={"entity_id": self.entity_id}, + ) + return options + @final @callback def _valid_option_or_raise(self, option: str) -> None: @@ -235,9 +247,9 @@ async def _async_offset_index(self, offset: int, cycle: bool) -> None: """Offset current index.""" current_index = 0 current_option = self.current_option - options = self.options - if current_option is not None and current_option in self.options: - current_index = self.options.index(current_option) + options = self._options_or_raise() + if current_option is not None and current_option in options: + current_index = options.index(current_option) new_index = current_index + offset if cycle: @@ -252,6 +264,6 @@ async def _async_offset_index(self, offset: int, cycle: bool) -> None: @final async def _async_select_index(self, idx: int) -> None: """Select new option by index.""" - options = self.options + options = self._options_or_raise() new_index = idx % len(options) await self.async_select_option(options[new_index]) diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 03a05f75bd93b..4f5b877d8c722 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -50,6 +50,9 @@ } }, "exceptions": { + "no_options": { + "message": "Entity {entity_id} has no options to select from." + }, "not_valid_option": { "message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}." } diff --git a/homeassistant/components/shelly/camera.py b/homeassistant/components/shelly/camera.py index 8e8662d906925..051561b57c19a 100644 --- a/homeassistant/components/shelly/camera.py +++ b/homeassistant/components/shelly/camera.py @@ -1,9 +1,11 @@ """Support for Shelly cameras.""" from dataclasses import dataclass -from typing import Final, override +from typing import TYPE_CHECKING, Final, override from urllib.parse import quote +from aioshelly.exceptions import DeviceConnectionError, HttpCallError, InvalidAuthError + from homeassistant.components.camera import ( Camera, CameraEntityDescription, @@ -11,8 +13,10 @@ ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( RpcEntityDescription, @@ -134,7 +138,31 @@ async def stream_source(self) -> str | None: return f"rtsp://{host}/stream/{self.entity_description.stream}" @override - @property - def use_stream_for_stills(self) -> bool: - """Use the RTSP stream to generate still images.""" - return True + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return a still image from the camera snapshot endpoint.""" + if TYPE_CHECKING: + assert self._id is not None + + try: + return await self.coordinator.device.camera_get_image(self._id) + except DeviceConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={ + "device": self.coordinator.name, + }, + ) from err + except HttpCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="http_call_error", + translation_placeholders={ + "device": self.coordinator.name, + }, + ) from err + except InvalidAuthError: + await self.coordinator.async_shutdown_device_and_start_reauth() + return None diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 17f4be9a709b4..8bff186c92f47 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -658,6 +658,9 @@ "firmware_unsupported": { "message": "{device} is running an unsupported firmware, please update the firmware" }, + "http_call_error": { + "message": "HTTP call error occurred for {device}" + }, "invalid_trigger": { "message": "Invalid device automation trigger (type, subtype): {trigger}" }, diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 06dfdf65ec321..4bfffd62de018 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from pytradfri import Gateway, RequestError -from pytradfri.api.aiocoap_api import APIFactory +from pytradfri.api.aiocoap_api import APIFactory, APIRequestProtocol from pytradfri.command import Command from pytradfri.device import Device @@ -56,12 +56,12 @@ async def on_hass_stop(event: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) - api = factory.request + api: APIRequestProtocol = factory.request gateway = Gateway() try: gateway_info = await api(gateway.get_gateway_info(), timeout=TIMEOUT_API) - devices_commands: Command = await api( + devices_commands: list[Command[Device]] = await api( gateway.get_devices(), timeout=TIMEOUT_API ) devices: list[Device] = await api(devices_commands, timeout=TIMEOUT_API) diff --git a/homeassistant/components/tradfri/coordinator.py b/homeassistant/components/tradfri/coordinator.py index 81919af711058..2bdd82bd3c92b 100644 --- a/homeassistant/components/tradfri/coordinator.py +++ b/homeassistant/components/tradfri/coordinator.py @@ -1,15 +1,14 @@ """Tradfri DataUpdateCoordinator.""" -from collections.abc import Callable from dataclasses import dataclass, field from datetime import timedelta -from typing import Any, override +from typing import cast, override from pytradfri import Gateway -from pytradfri.api.aiocoap_api import APIFactory -from pytradfri.command import Command +from pytradfri.api.aiocoap_api import APIFactory, APIRequestProtocol from pytradfri.device import Device from pytradfri.error import RequestError +from pytradfri.resource import ApiResource from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -28,7 +27,7 @@ class TradfriData: factory: APIFactory gateway: Gateway - api: Callable[[Command | list[Command]], Any] + api: APIRequestProtocol coordinator_list: list[TradfriDeviceDataUpdateCoordinator] = field( default_factory=list ) @@ -43,7 +42,7 @@ def __init__( self, hass: HomeAssistant, config_entry: TradfriConfigEntry, - api: Callable[[Command | list[Command]], Any], + api: APIRequestProtocol, device: Device, ) -> None: """Initialize device coordinator.""" @@ -67,9 +66,9 @@ async def set_hub_available(self, available: bool) -> None: await self.async_request_refresh() @callback - def _observe_update(self, device: Device) -> None: + def _observe_update(self, device: ApiResource) -> None: """Update the coordinator for a device when a change is detected.""" - self.async_set_updated_data(data=device) + self.async_set_updated_data(data=cast(Device, device)) @callback def _exception_callback(self, exc: Exception) -> None: diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index 96c0772289d1c..1ad25e41dba73 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -1,9 +1,8 @@ """Support for IKEA Tradfri covers.""" -from collections.abc import Callable -from typing import Any, cast, override +from typing import TYPE_CHECKING, Any, override -from pytradfri.command import Command +from pytradfri.api.aiocoap_api import APIRequestProtocol from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.core import HomeAssistant @@ -42,7 +41,7 @@ class TradfriCover(TradfriBaseEntity, CoverEntity): def __init__( self, device_coordinator: TradfriDeviceDataUpdateCoordinator, - api: Callable[[Command | list[Command]], Any], + api: APIRequestProtocol, gateway_id: str, ) -> None: """Initialize a switch.""" @@ -52,13 +51,15 @@ def __init__( gateway_id=gateway_id, ) + if TYPE_CHECKING: + assert self._device.blind_control is not None self._device_control = self._device.blind_control self._device_data = self._device_control.blinds[0] @override def _refresh(self) -> None: """Refresh the device.""" - self._device_data = self.coordinator.data.blind_control.blinds[0] + self._device_data = self._device_control.blinds[0] @property @override @@ -75,7 +76,7 @@ def current_cover_position(self) -> int | None: """ if not self._device_data: return None - return 100 - cast(int, self._device_data.current_cover_position) + return 100 - self._device_data.current_cover_position @override async def async_set_cover_position(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/tradfri/entity.py b/homeassistant/components/tradfri/entity.py index 306e361743d09..430f99f1a5226 100644 --- a/homeassistant/components/tradfri/entity.py +++ b/homeassistant/components/tradfri/entity.py @@ -3,10 +3,10 @@ from abc import abstractmethod from collections.abc import Callable, Coroutine from functools import wraps -from typing import Any, cast, override +from typing import Any, override +from pytradfri.api.aiocoap_api import APIRequestProtocol from pytradfri.command import Command -from pytradfri.const import ATTR_DEVICE_FIRMWARE_VERSION from pytradfri.device import Device from pytradfri.error import RequestError @@ -20,7 +20,7 @@ def handle_error( - func: Callable[[Command | list[Command]], Any], + func: APIRequestProtocol, ) -> Callable[[Command | list[Command]], Coroutine[Any, Any, None]]: """Handle tradfri api call error.""" @@ -44,7 +44,7 @@ def __init__( self, device_coordinator: TradfriDeviceDataUpdateCoordinator, gateway_id: str, - api: Callable[[Command | list[Command]], Any], + api: APIRequestProtocol, ) -> None: """Initialize a device.""" super().__init__(device_coordinator) @@ -62,7 +62,7 @@ def __init__( manufacturer=info.manufacturer, model=info.model_number, name=self._device.name, - sw_version=info.raw.get(ATTR_DEVICE_FIRMWARE_VERSION), + sw_version=info.firmware_version, via_device_id=dr.async_get_device_id_by_identifier( device_coordinator.hass, (DOMAIN, gateway_id), @@ -90,4 +90,4 @@ def _handle_coordinator_update(self) -> None: @override def available(self) -> bool: """Return if entity is available.""" - return cast(bool, self._device.reachable) and super().available + return self._device.reachable and super().available diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index 72ffbb3433482..d28126fe0e40a 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -1,9 +1,8 @@ """Represent an air purifier.""" -from collections.abc import Callable -from typing import Any, cast, override +from typing import TYPE_CHECKING, Any, override -from pytradfri.command import Command +from pytradfri.api.aiocoap_api import APIRequestProtocol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant @@ -69,7 +68,7 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): def __init__( self, device_coordinator: TradfriDeviceDataUpdateCoordinator, - api: Callable[[Command | list[Command]], Any], + api: APIRequestProtocol, gateway_id: str, ) -> None: """Initialize a switch.""" @@ -79,13 +78,15 @@ def __init__( gateway_id=gateway_id, ) + if TYPE_CHECKING: + assert self._device.air_purifier_control is not None self._device_control = self._device.air_purifier_control self._device_data = self._device_control.air_purifiers[0] @override def _refresh(self) -> None: """Refresh the device.""" - self._device_data = self.coordinator.data.air_purifier_control.air_purifiers[0] + self._device_data = self._device_control.air_purifiers[0] @property @override @@ -93,7 +94,7 @@ def is_on(self) -> bool: """Return true if switch is on.""" if not self._device_data: return False - return cast(bool, self._device_data.state) + return self._device_data.state @property @override diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index bd23b3dc1c3db..f95b7aa75b4c0 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -1,9 +1,8 @@ """Support for IKEA Tradfri lights.""" -from collections.abc import Callable -from typing import Any, cast, override +from typing import TYPE_CHECKING, Any, cast, override -from pytradfri.command import Command +from pytradfri.api.aiocoap_api import APIRequestProtocol from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -54,7 +53,7 @@ class TradfriLight(TradfriBaseEntity, LightEntity): def __init__( self, device_coordinator: TradfriDeviceDataUpdateCoordinator, - api: Callable[[Command | list[Command]], Any], + api: APIRequestProtocol, gateway_id: str, ) -> None: """Initialize a Light.""" @@ -64,6 +63,8 @@ def __init__( gateway_id=gateway_id, ) + if TYPE_CHECKING: + assert self._device.light_control is not None self._device_control = self._device.light_control self._device_data = self._device_control.lights[0] @@ -72,32 +73,27 @@ def __init__( # Calculate supported color modes modes: set[ColorMode] = {ColorMode.ONOFF} - if self._device.light_control.can_set_color: + if self._device_data.supports_hsb_xy_color: modes.add(ColorMode.HS) - if self._device.light_control.can_set_temp: + if self._device_data.supports_color_temp: modes.add(ColorMode.COLOR_TEMP) - if self._device.light_control.can_set_dimmer: + if self._device_data.supports_dimmer: modes.add(ColorMode.BRIGHTNESS) self._attr_supported_color_modes = filter_supported_color_modes(modes) if len(self._attr_supported_color_modes) == 1: self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) - if self._device_control: - self._attr_max_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin( - self._device_control.min_mireds - ) - ) - self._attr_min_color_temp_kelvin = ( - color_util.color_temperature_mired_to_kelvin( - self._device_control.max_mireds - ) - ) + self._attr_max_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( + self._device_control.min_mireds + ) + self._attr_min_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( + self._device_control.max_mireds + ) @override def _refresh(self) -> None: """Refresh the device.""" - self._device_data = self.coordinator.data.light_control.lights[0] + self._device_data = self._device_control.lights[0] @property @override @@ -105,7 +101,7 @@ def is_on(self) -> bool: """Return true if light is on.""" if not self._device_data: return False - return cast(bool, self._device_data.state) + return self._device_data.state @property @override @@ -137,23 +133,18 @@ def color_temp_kelvin(self) -> int | None: @override def hs_color(self) -> tuple[float, float] | None: """HS color of the light.""" - if not self._device_control or not self._device_data: + hsbxy = self._device_data.hsb_xy_color + if hsbxy is None: return None - if self._device_control.can_set_color: - hsbxy = self._device_data.hsb_xy_color - hue = hsbxy[0] / (self._device_control.max_hue / 360) - sat = hsbxy[1] / (self._device_control.max_saturation / 100) - if hue is not None and sat is not None: - return hue, sat - return None + hue = hsbxy[0] / (self._device_control.max_hue / 360) + sat = hsbxy[1] / (self._device_control.max_saturation / 100) + return hue, sat @override async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" # This allows transitioning to off, but resets the brightness # to 1 for the next set_state(True) command - if not self._device_control: - return transition_time = None if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) * 10 @@ -169,8 +160,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: @override async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - if not self._device_control: - return transition_time = None if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) * 10 @@ -189,59 +178,55 @@ async def async_turn_on(self, **kwargs: Any) -> None: dimmer_command = self._device_control.set_state(True) color_command = None - if ATTR_HS_COLOR in kwargs and self._device_control.can_set_color: + if ATTR_HS_COLOR in kwargs and self._device_data.supports_hsb_xy_color: hue = int(kwargs[ATTR_HS_COLOR][0] * (self._device_control.max_hue / 360)) sat = int( kwargs[ATTR_HS_COLOR][1] * (self._device_control.max_saturation / 100) ) - color_data = { - "hue": hue, - "saturation": sat, - "transition_time": transition_time, - } - color_command = self._device_control.set_hsb(**color_data) + color_command = self._device_control.set_hsb( + hue=hue, saturation=sat, transition_time=transition_time + ) transition_time = None temp_command = None if ATTR_COLOR_TEMP_KELVIN in kwargs and ( - self._device_control.can_set_temp or self._device_control.can_set_color + self._device_data.supports_color_temp + or self._device_data.supports_hsb_xy_color ): temp_k = kwargs[ATTR_COLOR_TEMP_KELVIN] # White Spectrum bulb - if self._device_control.can_set_temp: + if self._device_data.supports_color_temp: temp = color_util.color_temperature_kelvin_to_mired(temp_k) if temp < (min_mireds := self._device_control.min_mireds): temp = min_mireds elif temp > (max_mireds := self._device_control.max_mireds): temp = max_mireds - temp_data = { - "color_temp": temp, - "transition_time": transition_time, - } - temp_command = self._device_control.set_color_temp(**temp_data) + temp_command = self._device_control.set_color_temp( + color_temp=temp, transition_time=transition_time + ) transition_time = None # Color bulb (CWS) # color_temp needs to be set with hue/saturation - elif self._device_control.can_set_color: + elif self._device_data.supports_hsb_xy_color: hs_color = color_util.color_temperature_to_hs(temp_k) hue = int(hs_color[0] * (self._device_control.max_hue / 360)) sat = int(hs_color[1] * (self._device_control.max_saturation / 100)) - color_data = { - "hue": hue, - "saturation": sat, - "transition_time": transition_time, - } - color_command = self._device_control.set_hsb(**color_data) + color_command = self._device_control.set_hsb( + hue=hue, saturation=sat, transition_time=transition_time + ) transition_time = None # HSB can always be set, but color temp + brightness is bulb dependent - if (command := dimmer_command) is not None: - command += color_command - else: - command = color_command + command = dimmer_command + if color_command is not None: + command = self._device_control.combine_commands( + [dimmer_command, color_command] + ) - if self._device_control.can_combine_commands: - await self._api(command + temp_command) + if self._device_control.can_combine_commands and temp_command is not None: + await self._api( + self._device_control.combine_commands([command, temp_command]) + ) else: if temp_command is not None: await self._api(temp_command) diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json index e0488e0be390c..9743109c1d24b 100644 --- a/homeassistant/components/tradfri/manifest.json +++ b/homeassistant/components/tradfri/manifest.json @@ -10,5 +10,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pytradfri"], - "requirements": ["pytradfri[async]==9.0.1"] + "requirements": ["pytradfri[async]==14.0.0"] } diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index cbdda2bc4bccf..84b61e0997894 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Any, cast, override -from pytradfri.command import Command +from pytradfri.api.aiocoap_api import APIRequestProtocol from pytradfri.device import Device from homeassistant.components.sensor import ( @@ -38,17 +38,14 @@ def _get_air_quality(device: Device) -> int | None: ): # The sensor returns 65535 if the fan is turned off return None - return cast(int, device.air_purifier_control.air_purifiers[0].air_quality) + return device.air_purifier_control.air_purifiers[0].air_quality def _get_filter_time_left(device: Device) -> int: """Fetch the filter's remaining lifetime (in hours).""" assert device.air_purifier_control is not None return round( - cast( - int, device.air_purifier_control.air_purifiers[0].filter_lifetime_remaining - ) - / 60 + device.air_purifier_control.air_purifiers[0].filter_lifetime_remaining / 60 ) @@ -163,7 +160,7 @@ class TradfriSensor(TradfriBaseEntity, SensorEntity): def __init__( self, device_coordinator: TradfriDeviceDataUpdateCoordinator, - api: Callable[[Command | list[Command]], Any], + api: APIRequestProtocol, gateway_id: str, description: TradfriSensorEntityDescription, ) -> None: diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index 5eef92247f10b..1df5c529efc80 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -1,9 +1,8 @@ """Support for IKEA Tradfri switches.""" -from collections.abc import Callable -from typing import Any, cast, override +from typing import TYPE_CHECKING, Any, override -from pytradfri.command import Command +from pytradfri.api.aiocoap_api import APIRequestProtocol from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant @@ -42,7 +41,7 @@ class TradfriSwitch(TradfriBaseEntity, SwitchEntity): def __init__( self, device_coordinator: TradfriDeviceDataUpdateCoordinator, - api: Callable[[Command | list[Command]], Any], + api: APIRequestProtocol, gateway_id: str, ) -> None: """Initialize a switch.""" @@ -52,13 +51,15 @@ def __init__( gateway_id=gateway_id, ) + if TYPE_CHECKING: + assert self._device.socket_control is not None self._device_control = self._device.socket_control self._device_data = self._device_control.sockets[0] @override def _refresh(self) -> None: """Refresh the device.""" - self._device_data = self.coordinator.data.socket_control.sockets[0] + self._device_data = self._device_control.sockets[0] @property @override @@ -66,7 +67,7 @@ def is_on(self) -> bool: """Return true if switch is on.""" if not self._device_data: return False - return cast(bool, self._device_data.state) + return self._device_data.state @override async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index 140f9aba9759d..6a3b15e683358 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -18,6 +18,7 @@ VizioAuthError, VizioError, VizioNotFoundError, + VizioUnsupportedError, fetch_app_availability, fetch_remote_app_catalog, is_app_input, @@ -151,10 +152,9 @@ def __init__( update_interval=SCAN_INTERVAL, ) self.device = device - # Modern TV firmware bundles power/input/app state into one endpoint; - # firmware without it never gains it, so probe only until the first - # URI_NOT_FOUND response. Audio devices do not support this endpoint. - self._use_state_extended = device.profile.has_inputs + # Supported firmware bundles power/input/app state into one endpoint. + # Probe until the library reports that the endpoint is unavailable. + self._use_state_extended = True @override async def _async_setup(self) -> None: @@ -193,8 +193,10 @@ async def _async_update_data(self) -> VizioDeviceData: try: state = await self.device.get_state_extended() except VizioAuthError as err: - raise ConfigEntryAuthFailed from err - except VizioNotFoundError: + if self.device.profile.requires_auth: + raise ConfigEntryAuthFailed from err + self._use_state_extended = False + except VizioNotFoundError, VizioUnsupportedError: self._use_state_extended = False except VizioError as err: raise self._update_failed() from err diff --git a/homeassistant/components/waterfurnace/coordinator.py b/homeassistant/components/waterfurnace/coordinator.py index 0edf3abba8cc1..5932b8d6ccf59 100644 --- a/homeassistant/components/waterfurnace/coordinator.py +++ b/homeassistant/components/waterfurnace/coordinator.py @@ -93,7 +93,7 @@ def __init__( async def _async_update_data(self) -> WFReading: """Fetch data from WaterFurnace API with built-in retry logic.""" try: - return await self.hass.async_add_executor_job(self.client.read_with_retry) + return await self.hass.async_add_executor_job(self.client.read) except WFException as err: raise UpdateFailed(str(err)) from err diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json index 7125aac33dfd1..ead5767eaa0a1 100644 --- a/homeassistant/components/waterfurnace/manifest.json +++ b/homeassistant/components/waterfurnace/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["waterfurnace"], "quality_scale": "bronze", - "requirements": ["waterfurnace==1.9.2"] + "requirements": ["waterfurnace==1.9.7"] } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bd717e766b7f2..f5a0279fd6604 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -92,6 +92,7 @@ "awair", "aws_s3", "axis", + "axle_energy", "azure_data_explorer", "azure_devops", "azure_event_hub", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6071ed15de23a..1acbd8dae166c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -669,6 +669,12 @@ "config_flow": true, "iot_class": "local_push" }, + "axle_energy": { + "name": "Axle Energy", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "backblaze_b2": { "name": "Backblaze B2", "integration_type": "service", diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 2f11201747907..e669171a5d162 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -873,6 +873,11 @@ def non_empty_string(value: Any) -> str: return value_str +def is_blank_slot_value(value: Any) -> bool: + """Return if an LLM tool's blank value should be omitted from intent slots.""" + return value is None or (isinstance(value, str) and not value.strip()) + + @dataclass(kw_only=True) class IntentSlotInfo: """Details about how intent slots are processed and validated.""" diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 07b69a8f57a61..517b9fbe85bdd 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -255,7 +255,11 @@ async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext ) -> JsonObjectType: """Handle the intent.""" - slots = {key: {"value": val} for key, val in tool_input.tool_args.items()} + slots = { + key: {"value": val} + for key, val in tool_input.tool_args.items() + if not intent.is_blank_slot_value(val) + } if self.extra_slots and llm_context.device_id: device_reg = dr.async_get(hass) diff --git a/mypy.ini b/mypy.ini index 9f25acf32c2a6..b25989bc0da80 100644 --- a/mypy.ini +++ b/mypy.ini @@ -847,6 +847,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.axle_energy.*] +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.azure_storage.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 9b0c2eb4d5c2f..03937333354dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -44,7 +44,7 @@ PSNAWP==3.0.3 Pillow==12.3.0 # homeassistant.components.plex -PlexAPI==4.15.16 +PlexAPI==4.18.2 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -217,6 +217,9 @@ aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower aioautomower==3.0.0 +# homeassistant.components.axle_energy +aioaxlevpp==0.1.0 + # homeassistant.components.azure_devops aioazuredevops==2.2.2 @@ -2720,7 +2723,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.17.0 +python-duco-connectivity==0.18.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 @@ -2753,7 +2756,7 @@ python-homeassistant-analytics==0.9.0 python-homewizard-energy==10.2.0 # homeassistant.components.hotspring -python-hotspring==2.1.0 +python-hotspring==3.0.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -2869,7 +2872,7 @@ pytouchlinesl==0.6.0 pytraccar==3.0.0 # homeassistant.components.tradfri -pytradfri[async]==9.0.1 +pytradfri[async]==14.0.0 # homeassistant.components.trafikverket_camera # homeassistant.components.trafikverket_ferry @@ -3440,7 +3443,7 @@ wallbox==0.9.0 watchdog==6.0.0 # homeassistant.components.waterfurnace -waterfurnace==1.9.2 +waterfurnace==1.9.7 # homeassistant.components.watergate watergate-local-api==2026.2.2 diff --git a/tests/components/axle_energy/__init__.py b/tests/components/axle_energy/__init__.py new file mode 100644 index 0000000000000..f499573e36b0d --- /dev/null +++ b/tests/components/axle_energy/__init__.py @@ -0,0 +1 @@ +"""Tests for Axle Energy.""" diff --git a/tests/components/axle_energy/conftest.py b/tests/components/axle_energy/conftest.py new file mode 100644 index 0000000000000..c39389e2ef2b0 --- /dev/null +++ b/tests/components/axle_energy/conftest.py @@ -0,0 +1,58 @@ +"""Fixtures for Axle Energy.""" + +from collections.abc import Iterator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from aioaxlevpp import GridEvent +import pytest + +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Iterator[AsyncMock]: + """Skip integration setup during config flow tests.""" + with patch( + "homeassistant.components.axle_energy.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_event() -> GridEvent: + """A synthetic future export event.""" + return GridEvent( + start=datetime(2026, 9, 11, 17, tzinfo=UTC), + end=datetime(2026, 9, 11, 18, tzinfo=UTC), + direction="export", + updated_at=datetime(2026, 9, 11, 8, tzinfo=UTC), + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """One Axle household.""" + return MockConfigEntry( + domain="axle_energy", + title="Axle Energy", + entry_id="test-entry", + data={CONF_API_KEY: "test-token"}, + ) + + +@pytest.fixture(autouse=True) +def mock_client(mock_event: GridEvent) -> Iterator[AsyncMock]: + """Mock the external dependency at the integration boundary.""" + with ( + patch( + "homeassistant.components.axle_energy.AxleClient", autospec=True + ) as client, + patch( + "homeassistant.components.axle_energy.config_flow.AxleClient", new=client + ), + ): + client.return_value.get_event.return_value = mock_event + yield client.return_value diff --git a/tests/components/axle_energy/snapshots/test_sensor.ambr b/tests/components/axle_energy/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..1d744a744242a --- /dev/null +++ b/tests/components/axle_energy/snapshots/test_sensor.ambr @@ -0,0 +1,163 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.axle_energy_event_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.axle_energy_event_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Event end', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Event end', + 'platform': 'axle_energy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'end', + 'unique_id': 'test-entry_end', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.axle_energy_event_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Axle Energy Event end', + }), + 'context': , + 'entity_id': 'sensor.axle_energy_event_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-09-11T18:00:00+00:00', + }) +# --- +# name: test_sensor_snapshot[sensor.axle_energy_event_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.axle_energy_event_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Event start', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Event start', + 'platform': 'axle_energy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'test-entry_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.axle_energy_event_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Axle Energy Event start', + }), + 'context': , + 'entity_id': 'sensor.axle_energy_event_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-09-11T17:00:00+00:00', + }) +# --- +# name: test_sensor_snapshot[sensor.axle_energy_event_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'import', + 'export', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.axle_energy_event_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Event type', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Event type', + 'platform': 'axle_energy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'import_export', + 'unique_id': 'test-entry_import_export', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.axle_energy_event_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Axle Energy Event type', + : list([ + 'import', + 'export', + ]), + }), + 'context': , + 'entity_id': 'sensor.axle_energy_event_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'export', + }) +# --- diff --git a/tests/components/axle_energy/test_config_flow.py b/tests/components/axle_energy/test_config_flow.py new file mode 100644 index 0000000000000..1faf6ecdf08e1 --- /dev/null +++ b/tests/components/axle_energy/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Axle configuration flow.""" + +from unittest.mock import AsyncMock + +from aioaxlevpp import AxleAuthenticationError, AxleConnectionError, AxleError +import pytest + +from homeassistant.components.axle_energy.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user(hass: HomeAssistant) -> None: + """Configure the feed through the UI.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "test-token"} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "test-token"} + assert result["title"] == "Axle Energy" + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (AxleAuthenticationError(), "invalid_auth"), + (AxleConnectionError(), "cannot_connect"), + (AxleError(), "cannot_connect"), + ], +) +async def test_user_errors( + hass: HomeAssistant, mock_client: AsyncMock, error: Exception, message: str +) -> None: + """Show recoverable setup failures.""" + mock_client.get_event.side_effect = error + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "test-token"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": message} + mock_client.get_event.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "replacement-token"} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "replacement-token"} + assert result["title"] == "Axle Energy" + + +async def test_duplicate( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: AsyncMock +) -> None: + """Reject the same key without making another API request.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "test-token"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_client.get_event.assert_not_called() diff --git a/tests/components/axle_energy/test_init.py b/tests/components/axle_energy/test_init.py new file mode 100644 index 0000000000000..b1af5b37ed008 --- /dev/null +++ b/tests/components/axle_energy/test_init.py @@ -0,0 +1,56 @@ +"""Test integration setup and unloading.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from aioaxlevpp import AxleAuthenticationError, AxleConnectionError +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Unload the entry and its entities.""" + 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() + 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 + assert hass.states.get("sensor.axle_energy_event_type").state == "unavailable" + mock_client.get_event.reset_mock() + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_client.get_event.assert_not_called() + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (AxleAuthenticationError(), ConfigEntryState.SETUP_ERROR), + (AxleConnectionError(), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + error: Exception, + expected: ConfigEntryState, +) -> None: + """Handle authentication and retryable connection failures.""" + mock_client.get_event.side_effect = error + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is expected diff --git a/tests/components/axle_energy/test_sensor.py b/tests/components/axle_energy/test_sensor.py new file mode 100644 index 0000000000000..fbc1a3de71937 --- /dev/null +++ b/tests/components/axle_energy/test_sensor.py @@ -0,0 +1,156 @@ +"""Test event metadata and coordinator lifecycle.""" + +from dataclasses import replace +from datetime import timedelta +from unittest.mock import AsyncMock + +from aioaxlevpp import ( + AxleAuthenticationError, + AxleConnectionError, + AxleError, + GridEvent, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def setup(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Load the integration.""" + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sensor_snapshot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the registered sensors and their state attributes.""" + await setup(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "value", "key"), + [ + ("sensor.axle_energy_event_type", "export", "import_export"), + ("sensor.axle_energy_event_start", "2026-09-11T17:00:00+00:00", "start"), + ("sensor.axle_energy_event_end", "2026-09-11T18:00:00+00:00", "end"), + ], +) +async def test_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + entity_id: str, + value: str, + key: str, +) -> None: + """Expose the three event inputs with persistent identities.""" + await setup(hass, mock_config_entry) + assert hass.states.get(entity_id).state == value + registered = entity_registry.async_get(entity_id) + assert registered is not None + assert registered.config_entry_id == mock_config_entry.entry_id + assert registered.unique_id == f"{mock_config_entry.entry_id}_{key}" + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 3 + ) + + +async def test_no_event( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: AsyncMock +) -> None: + """An empty schedule is unknown, not a failed connection.""" + mock_client.get_event.return_value = None + await setup(hass, mock_config_entry) + assert hass.states.get("sensor.axle_energy_event_type").state == "unknown" + + +async def test_opted_out( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + mock_event: GridEvent, +) -> None: + """Exclude an event the household has opted out of.""" + mock_client.get_event.return_value = replace(mock_event, opted_out=True) + await setup(hass, mock_config_entry) + assert hass.states.get("sensor.axle_energy_event_type").state == "unknown" + + +@pytest.mark.parametrize("error", [AxleConnectionError(), AxleError()]) +async def test_recovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + freezer: FrozenDateTimeFactory, + error: Exception, +) -> None: + """Recover from a failed request without confusing it with no event.""" + await setup(hass, mock_config_entry) + mock_client.get_event.side_effect = error + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("sensor.axle_energy_event_type").state == "unavailable" + mock_client.get_event.side_effect = None + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("sensor.axle_energy_event_type").state == "export" + + +async def test_polling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + mock_event: GridEvent, + freezer: FrozenDateTimeFactory, +) -> None: + """Poll at ten minutes and publish revised direction.""" + await setup(hass, mock_config_entry) + mock_client.get_event.reset_mock() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_client.get_event.assert_not_called() + mock_client.get_event.return_value = replace(mock_event, direction="import") + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_client.get_event.assert_awaited_once() + assert hass.states.get("sensor.axle_energy_event_type").state == "import" + + +async def test_authentication_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Stop polling after the service rejects the credentials.""" + await setup(hass, mock_config_entry) + mock_client.get_event.side_effect = AxleAuthenticationError() + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("sensor.axle_energy_event_type").state == "unavailable" + mock_client.get_event.reset_mock() + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_client.get_event.assert_not_called() diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 1792f80d6685c..ab3d7aa2b9915 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -10,8 +10,11 @@ PowerSwitchService, SHCBatteryDevice, SHCLightSwitchBSM, + SHCMicromoduleBlinds, SHCMicromoduleRelay, + SHCShutterControl, SHCThermostat, + ShutterControlService, ThermostatService, ) import pytest @@ -64,6 +67,7 @@ def mock_config_entry() -> MockConfigEntry: "roomthermostats", "shutter_contacts", "shutter_contacts2", + "shutter_controls", "smart_plugs", "smart_plugs_compact", "smoke_detectors", @@ -134,6 +138,53 @@ def battery_only_device( return device +def shutter_control_device( + device_id: str = "hdm:ZigBee:shutter1", + name: str = "Shutter", + device_model: str = "BBL", + level: float = 1.0, + operation_state: ShutterControlService.State = ShutterControlService.State.STOPPED, +) -> SHCShutterControl: + """Build a minimal device double for the shutter_controls/micromodule_shutter_controls buckets.""" + device = create_autospec(SHCShutterControl, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = device_model + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.level = level + device.operation_state = operation_state + return device + + +def micromodule_blinds_device( + device_id: str = "hdm:ZigBee:blinds1", + name: str = "Blinds", + level: float = 1.0, + current_angle: float = 0.0, + operation_state: ShutterControlService.State = ShutterControlService.State.STOPPED, +) -> SHCMicromoduleBlinds: + """Build a minimal device double for the micromodule_blinds bucket.""" + device = create_autospec(SHCMicromoduleBlinds, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "MICROMODULE_BLINDS" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.level = level + device.current_angle = current_angle + device.operation_state = operation_state + return device + + def thermostat_device( device_id: str = "hdm:ZigBee:thermostat1", name: str = "Thermostat", diff --git a/tests/components/bosch_shc/test_cover.py b/tests/components/bosch_shc/test_cover.py new file mode 100644 index 0000000000000..a826ba94fb177 --- /dev/null +++ b/tests/components/bosch_shc/test_cover.py @@ -0,0 +1,190 @@ +"""Tests for the Bosch SHC cover platform.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + Platform, +) +from homeassistant.core import HomeAssistant + +from .conftest import ( + micromodule_blinds_device, + setup_integration, + shutter_control_device, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def platforms() -> Generator[None]: + """Restrict bosch_shc setup to the cover platform.""" + with patch("homeassistant.components.bosch_shc.PLATFORMS", [Platform.COVER]): + yield + + +@pytest.mark.parametrize( + "device_buckets", + [{"shutter_controls": [shutter_control_device(device_model="BBL", level=0.75)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_bbl_shutter_control( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A plain BBL shutter is exposed as a shutter-class cover.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.shutter_controls[0] + + state = hass.states.get("cover.shutter") + assert state is not None + assert state.attributes["device_class"] == "shutter" + assert state.attributes["current_position"] == 75 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.shutter"}, + blocking=True, + ) + assert device.level == 1.0 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.shutter"}, + blocking=True, + ) + assert device.level == 0.0 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.shutter", ATTR_POSITION: 42}, + blocking=True, + ) + assert device.level == pytest.approx(0.42) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.shutter"}, + blocking=True, + ) + device.stop.assert_called_once() + + +@pytest.mark.parametrize( + ("device_buckets", "expected_device_class"), + [ + pytest.param( + { + "micromodule_shutter_controls": [ + shutter_control_device(device_model="MICROMODULE_SHUTTER") + ] + }, + "shutter", + id="micromodule_shutter", + ), + pytest.param( + { + "micromodule_shutter_controls": [ + shutter_control_device(device_model="MICROMODULE_AWNING") + ] + }, + "awning", + id="micromodule_awning", + ), + ], + indirect=["device_buckets"], +) +@pytest.mark.usefixtures("mock_session") +async def test_micromodule_shutter_device_class( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + expected_device_class: str, +) -> None: + """A micromodule shutter/awning device (#181407) gets the right device class.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("cover.shutter") + assert state is not None + assert state.attributes["device_class"] == expected_device_class + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "micromodule_blinds": [ + micromodule_blinds_device(level=0.6, current_angle=0.25) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_micromodule_blinds_tilt( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Micromodule blinds (#181407) expose tilt controls in addition to lift.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.micromodule_blinds[0] + + state = hass.states.get("cover.blinds") + assert state is not None + assert state.attributes["device_class"] == "blind" + assert state.attributes["current_position"] == 60 + assert state.attributes["current_tilt_position"] == 75 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.blinds"}, + blocking=True, + ) + assert device.target_angle == 0.0 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.blinds"}, + blocking=True, + ) + assert device.target_angle == 1.0 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.blinds", ATTR_TILT_POSITION: 30}, + blocking=True, + ) + assert device.target_angle == pytest.approx(0.7) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.blinds"}, + blocking=True, + ) + device.stop_blinds.assert_called_once() diff --git a/tests/components/climate/test_llm.py b/tests/components/climate/test_llm.py index ba91a1c5d3122..1259f36a5fa6f 100644 --- a/tests/components/climate/test_llm.py +++ b/tests/components/climate/test_llm.py @@ -3,12 +3,20 @@ import pytest from homeassistant.components import llm as llm_component -from homeassistant.components.climate import llm as climate_llm +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + DOMAIN, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, + 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 +from tests.common import async_mock_service + ENTITY_ID = "climate.test" @@ -19,7 +27,14 @@ async def setup_integrations(hass: HomeAssistant) -> None: 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"}) + hass.states.async_set( + ENTITY_ID, + "on", + { + "friendly_name": "Test climate", + "supported_features": ClimateEntityFeature.TARGET_TEMPERATURE, + }, + ) async_expose_entity(hass, "conversation", ENTITY_ID, True) await hass.async_block_till_done() @@ -46,6 +61,41 @@ async def test_intent_tool_exposed(hass: HomeAssistant) -> None: assert "climate__HassClimateSetTemperature" in await _tool_names(hass) +@pytest.mark.parametrize( + "target_args", + [ + pytest.param( + {"area": "", "floor": "", "name": "Test climate"}, + id="named-target", + ), + pytest.param( + {"area": "", "floor": " ", "name": None}, + id="implicit-single-target", + ), + ], +) +async def test_set_temperature_omits_empty_optional_targets( + hass: HomeAssistant, target_args: dict[str, str | None] +) -> None: + """Test empty optional targets do not invalidate a climate LLM tool call.""" + api = await llm.async_get_api(hass, "assist", _llm_context()) + calls = async_mock_service(hass, DOMAIN, SERVICE_SET_TEMPERATURE) + + response = await api.async_call_tool( + llm.ToolInput( + "climate__HassClimateSetTemperature", + {**target_args, "temperature": 25}, + ) + ) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": ENTITY_ID, + ATTR_TEMPERATURE: 25, + } + assert response["response_type"] == "action_done" + + 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) diff --git a/tests/components/collection_image/helpers.py b/tests/components/collection_image/helpers.py index 9e14f8d1d5316..b842fc28faf27 100644 --- a/tests/components/collection_image/helpers.py +++ b/tests/components/collection_image/helpers.py @@ -1,29 +1,37 @@ """Helper utilities for collection image tests.""" -from homeassistant.components.collection_image.const import DOMAIN +from typing import Any + +from homeassistant.components.collection_image.const import CONF_MEDIA, DOMAIN from homeassistant.components.media_player import BrowseMedia, MediaClass from homeassistant.components.media_source import BrowseMediaSource from tests.common import MockConfigEntry -def config_entry_from_uri(uri: str | list[str]) -> MockConfigEntry: - """Construct a mock config entry from one URI or a list of URIs.""" +def data_from_uri(uri: str | list[str]) -> dict[str, Any]: + """Construct a data entry from one URI or a list of URIs.""" - def media_item(content_id: str) -> dict[str, str]: + def media_item(content_id: str) -> dict[str, Any]: return { "media_content_id": content_id, "media_content_type": "", + "metadata": {"a": "b"}, } - media: dict[str, str] | list[dict[str, str]] + media: dict[str, Any] | list[dict[str, Any]] if isinstance(uri, str): media = media_item(uri) else: media = [media_item(item) for item in uri] + return {CONF_MEDIA: media} + + +def config_entry_from_uri(uri: str | list[str]) -> MockConfigEntry: + """Construct a mock config entry from one URI or a list of URIs.""" return MockConfigEntry( - data={"media": media}, + data=data_from_uri(uri), domain=DOMAIN, title="Random Image", ) diff --git a/tests/components/collection_image/test_config_flow.py b/tests/components/collection_image/test_config_flow.py index f832ec33c99dc..79fcae996558d 100644 --- a/tests/components/collection_image/test_config_flow.py +++ b/tests/components/collection_image/test_config_flow.py @@ -1,14 +1,15 @@ """Test the Collection Image config flow.""" -from unittest.mock import AsyncMock, patch - +from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config_entries from homeassistant.components.collection_image.config_flow import IMAGE_MEDIA_URI -from homeassistant.components.collection_image.const import DOMAIN +from homeassistant.components.collection_image.const import CONF_MEDIA, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.util import slugify from .const import ( MOCK_MEDIA_DIR_URI_1, @@ -16,30 +17,12 @@ MOCK_MEDIA_DIR_URI_BROWSE_ERROR, MOCK_MEDIA_DIR_URI_EMPTY, ) +from .helpers import data_from_uri +from tests.common import MockConfigEntry -@pytest.fixture -def mock_setup_entry(): - """Mock collection_image setup successfully.""" - - with patch( - "homeassistant.components.collection_image.async_setup_entry", - new=AsyncMock(return_value=True), - ) as mock_setup: - yield mock_setup - - -def _data_from_uris(uris: list[str]) -> dict: - return { - "media": [ - { - "media_content_id": uri, - "media_content_type": "", - "metadata": {"a": "b"}, - } - for uri in uris - ] - } +TEST_TIME = "2026-09-12T07:12:00+00:00" +TEST_TIME_NEXT = "2026-09-12T07:30:00+00:00" @pytest.mark.parametrize( @@ -51,8 +34,9 @@ def _data_from_uris(uris: list[str]) -> dict: ], ) @pytest.mark.usefixtures("mock_media_source") +@freeze_time(TEST_TIME) async def test_config_flow( - hass: HomeAssistant, mock_setup_entry, uris: list[str], expected_title: str + hass: HomeAssistant, uris: list[str], expected_title: str ) -> None: """Test the config flow.""" @@ -62,14 +46,18 @@ async def test_config_flow( assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} - data = _data_from_uris(uris) + data = data_from_uri(uris) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result.get("type") is FlowResultType.CREATE_ENTRY assert result.get("title") == expected_title assert result.get("data") == data - assert len(mock_setup_entry.mock_calls) == 1 + + await hass.async_block_till_done() + + state = hass.states.get(f"image.{slugify(expected_title)}") + assert state and state.state == TEST_TIME @pytest.mark.parametrize( @@ -106,10 +94,10 @@ async def test_config_flow( ), ], ) +@freeze_time(TEST_TIME) @pytest.mark.usefixtures("mock_media_source") async def test_config_flow_error( hass: HomeAssistant, - mock_setup_entry, uris: list[str], error: str, placeholders: dict, @@ -122,7 +110,7 @@ async def test_config_flow_error( assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} - data = _data_from_uris(uris) + data = data_from_uri(uris) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) await hass.async_block_till_done() @@ -133,21 +121,20 @@ async def test_config_flow_error( media_key = next( key for key in result["data_schema"].schema - if getattr(key, "schema", key) == "media" + if getattr(key, "schema", key) == CONF_MEDIA ) for idx, uri in enumerate(uris): assert media_key.description["suggested_value"][idx]["media_content_id"] == uri assert ( media_key.description["suggested_value"][idx]["metadata"] - == data["media"][idx]["metadata"] + == data[CONF_MEDIA][idx]["metadata"] ) - assert result.get("errors") == {"media": error} + assert result.get("errors") == {CONF_MEDIA: error} assert result.get("description_placeholders") == placeholders - assert len(mock_setup_entry.mock_calls) == 0 # Try again successfully to ensure we can recover from errors - data = _data_from_uris([MOCK_MEDIA_DIR_URI_1]) + data = data_from_uri([MOCK_MEDIA_DIR_URI_1]) expected_title = "My pictures collection" result = await hass.config_entries.flow.async_configure(result["flow_id"], data) @@ -155,4 +142,93 @@ async def test_config_flow_error( assert result.get("type") is FlowResultType.CREATE_ENTRY assert result.get("title") == expected_title assert result.get("data") == data - assert len(mock_setup_entry.mock_calls) == 1 + + await hass.async_block_till_done() + + state = hass.states.get(f"image.{slugify(expected_title)}") + assert state and state.state == TEST_TIME + + +@pytest.mark.parametrize( + ("entry_data", "expected_uri"), + [ + pytest.param( + data_from_uri([MOCK_MEDIA_DIR_URI_1]), + MOCK_MEDIA_DIR_URI_1, + id="legacy-data-array", + ), + pytest.param( + data_from_uri(MOCK_MEDIA_DIR_URI_1), + MOCK_MEDIA_DIR_URI_1, + id="legacy-data-scalar", + ), + ], +) +@pytest.mark.usefixtures("mock_media_source") +async def test_reconfigure_flow( + hass: HomeAssistant, + entry_data: dict, + expected_uri: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test reconfigure flow loads the original data and can update media.""" + freezer.move_to(TEST_TIME) + entry = MockConfigEntry( + domain=DOMAIN, + title="Test collection", + data=entry_data, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("image.test_collection") + assert state and state.state == TEST_TIME + + freezer.move_to(TEST_TIME_NEXT) + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + media_key = next( + key + for key in result["data_schema"].schema + if getattr(key, "schema", key) == CONF_MEDIA + ) + assert ( + media_key.description["suggested_value"][0]["media_content_id"] == expected_uri + ) + + # First try new data with error + new_data = data_from_uri([MOCK_MEDIA_DIR_URI_EMPTY]) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + new_data, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("data") is None + assert result.get("errors") == {CONF_MEDIA: "selected_media_no_images"} + + # Now update again with a valid option, to recover + new_data = data_from_uri([MOCK_MEDIA_DIR_URI_2]) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + new_data, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + updated_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert updated_entry is not None + assert updated_entry.data == new_data + + await hass.async_block_till_done() + + state = hass.states.get("image.test_collection") + assert state and state.state == TEST_TIME_NEXT diff --git a/tests/components/hotspring/conftest.py b/tests/components/hotspring/conftest.py index 3cf62812ed6f3..c7d66bb6a40f2 100644 --- a/tests/components/hotspring/conftest.py +++ b/tests/components/hotspring/conftest.py @@ -107,13 +107,13 @@ def device_fixture() -> Spa: boost_active=False, salt_value=12, ) - spa.jets = [ - Jet(jet_id=1, speed=JetSpeed.OFF, is_enabled=True, on_seconds=0), - Jet(jet_id=2, speed=JetSpeed.OFF, is_enabled=True, on_seconds=0), - ] + spa.jets = { + 1: Jet(jet_id=1, speed=JetSpeed.OFF, is_enabled=True, on_seconds=0), + 2: Jet(jet_id=2, speed=JetSpeed.OFF, is_enabled=True, on_seconds=0), + } spa.blower = Blower(is_enabled=False, is_on=False) - spa.light_zones = [ - LightZone( + spa.light_zones = { + 1: LightZone( zone_id=1, is_enabled=True, is_on=False, @@ -122,7 +122,7 @@ def device_fixture() -> Spa: intensity=0, loop_speed=0, ), - ] + } spa.logo_light = LogoLight(brightness=BrightnessLevel.LEVEL_1) spa.clean_cycle = CleanCycle(is_enabled=False, vanishing_act=False) spa.spa_lock = SpaLock(is_locked=False) @@ -134,9 +134,11 @@ def device_fixture() -> Spa: sensor_life_percentage=100.0, installed=False, ) - spa.energy_savings = [ - EnergySaving(schedule_id=1, mode=0, start_hour=0, start_minute=0, duration=0), - ] + spa.energy_savings = { + 1: EnergySaving( + schedule_id=1, mode=0, start_hour=0, start_minute=0, duration=0 + ), + } spa.connection_status = ConnectionStatus(spa_connected=True) spa.diagnostics = Diagnostics( spa_failure_state=SpaFailureState.OK, diff --git a/tests/components/hotspring/snapshots/test_diagnostics.ambr b/tests/components/hotspring/snapshots/test_diagnostics.ambr index 36b8e021c4f5c..007f53ff7a1c6 100644 --- a/tests/components/hotspring/snapshots/test_diagnostics.ambr +++ b/tests/components/hotspring/snapshots/test_diagnostics.ambr @@ -7,13 +7,17 @@ 'is_on': False, }), 'clean_cycle': dict({ + 'clean_timer_enabled': False, 'is_enabled': False, + 'start_hour': 0, + 'start_minute': 0, 'vanishing_act': False, }), 'connection_status': dict({ 'spa_connected': True, }), 'diagnostics': dict({ + 'circulation_pump_flow_status': '0', 'heater_error': '0', 'heater_power': '0', 'heater_volts': 240.0, @@ -22,8 +26,10 @@ 'jet3_volts': 0.0, 'l1_n_volts': 120.0, 'l2_n_volts': 120.0, + 'limit_thermistor_status': '0', 'power_frequency': '60', 'pressure_switch_status': '0', + 'regulation_thermistor_temp_status': '0', 'small_loads_power': '0', 'spa_failure_state': dict({ '__type': "", @@ -82,6 +88,8 @@ }), 'jets': list([ dict({ + 'concurrent_mode': False, + 'current': 0.0, 'is_enabled': True, 'jet_id': 1, 'on_seconds': 0, @@ -89,8 +97,14 @@ '__type': "", 'repr': "", }), + 'speed_type': dict({ + '__type': "", + 'repr': "", + }), }), dict({ + 'concurrent_mode': False, + 'current': 0.0, 'is_enabled': True, 'jet_id': 2, 'on_seconds': 0, @@ -98,6 +112,10 @@ '__type': "", 'repr': "", }), + 'speed_type': dict({ + '__type': "", + 'repr': "", + }), }), ]), 'light_zones': list([ @@ -157,6 +175,8 @@ 'cartridge_installed': True, 'level': 2, 'one_twenty_day_timer': 117, + 'power_a': False, + 'power_b': False, 'salt_value': 12, 'system_enabled': True, 'ten_day_timer': 0, @@ -175,13 +195,17 @@ 'is_on': False, }), 'clean_cycle': dict({ + 'clean_timer_enabled': False, 'is_enabled': False, + 'start_hour': 0, + 'start_minute': 0, 'vanishing_act': False, }), 'connection_status': dict({ 'spa_connected': True, }), 'diagnostics': dict({ + 'circulation_pump_flow_status': '0', 'heater_error': '0', 'heater_power': '0', 'heater_volts': 240.0, @@ -190,8 +214,10 @@ 'jet3_volts': 0.0, 'l1_n_volts': 120.0, 'l2_n_volts': 120.0, + 'limit_thermistor_status': '0', 'power_frequency': '60', 'pressure_switch_status': '0', + 'regulation_thermistor_temp_status': '0', 'small_loads_power': '0', 'spa_failure_state': dict({ '__type': "", @@ -250,6 +276,8 @@ }), 'jets': list([ dict({ + 'concurrent_mode': False, + 'current': 0.0, 'is_enabled': True, 'jet_id': 1, 'on_seconds': 0, @@ -257,8 +285,14 @@ '__type': "", 'repr': "", }), + 'speed_type': dict({ + '__type': "", + 'repr': "", + }), }), dict({ + 'concurrent_mode': False, + 'current': 0.0, 'is_enabled': True, 'jet_id': 2, 'on_seconds': 0, @@ -266,6 +300,10 @@ '__type': "", 'repr': "", }), + 'speed_type': dict({ + '__type': "", + 'repr': "", + }), }), ]), 'light_zones': list([ @@ -325,6 +363,8 @@ 'cartridge_installed': True, 'level': 2, 'one_twenty_day_timer': 117, + 'power_a': False, + 'power_b': False, 'salt_value': 12, 'system_enabled': True, 'ten_day_timer': 0, diff --git a/tests/components/hotspring/test_light.py b/tests/components/hotspring/test_light.py index 4d247af269186..7eeaad9d9f22e 100644 --- a/tests/components/hotspring/test_light.py +++ b/tests/components/hotspring/test_light.py @@ -60,8 +60,8 @@ async def test_turn_on_default( assert state.attributes.get(ATTR_BRIGHTNESS) is None def _set_light_brightness(zone: int, brightness: int) -> None: - device_fixture.light_zones[0].intensity = brightness - device_fixture.light_zones[0].is_on = brightness > 0 + device_fixture.light_zones[1].intensity = brightness + device_fixture.light_zones[1].is_on = brightness > 0 mock_hotspring.set_light_brightness.side_effect = _set_light_brightness @@ -88,8 +88,8 @@ async def test_turn_on_with_brightness( """Test turning on light with brightness.""" def _set_light_brightness(zone: int, brightness: int) -> None: - device_fixture.light_zones[0].intensity = brightness - device_fixture.light_zones[0].is_on = brightness > 0 + device_fixture.light_zones[1].intensity = brightness + device_fixture.light_zones[1].is_on = brightness > 0 mock_hotspring.set_light_brightness.side_effect = _set_light_brightness @@ -137,8 +137,8 @@ async def test_turn_on_with_rgb_color_when_on( device_fixture: Spa, ) -> None: """Test setting rgb color when light is already on.""" - device_fixture.light_zones = [ - LightZone( + device_fixture.light_zones = { + 1: LightZone( zone_id=1, is_enabled=True, is_on=True, @@ -147,7 +147,7 @@ async def test_turn_on_with_rgb_color_when_on( intensity=3, loop_speed=0, ), - ] + } await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) await hass.services.async_call( @@ -192,8 +192,8 @@ async def test_rgb_color_active_custom( device_fixture: Spa, ) -> None: """Test rgb_color property returns custom RGB values when active.""" - device_fixture.light_zones = [ - LightZone( + device_fixture.light_zones = { + 1: LightZone( zone_id=1, is_enabled=True, is_on=True, @@ -206,7 +206,7 @@ async def test_rgb_color_active_custom( c_blue=50, rgb_state="active", ), - ] + } await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) state = hass.states.get(ENTITY_ID) @@ -221,8 +221,8 @@ async def test_rgb_color_all_zero( device_fixture: Spa, ) -> None: """Test rgb_color property returns (0, 0, 0) when rgb_state is active.""" - device_fixture.light_zones = [ - LightZone( + device_fixture.light_zones = { + 1: LightZone( zone_id=1, is_enabled=True, is_on=True, @@ -235,7 +235,7 @@ async def test_rgb_color_all_zero( c_blue=0, rgb_state="active", ), - ] + } await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) state = hass.states.get(ENTITY_ID) @@ -250,8 +250,8 @@ async def test_turn_off( device_fixture: Spa, ) -> None: """Test turning off light.""" - device_fixture.light_zones = [ - LightZone( + device_fixture.light_zones = { + 1: LightZone( zone_id=1, is_enabled=True, is_on=True, @@ -260,12 +260,12 @@ async def test_turn_off( intensity=5, loop_speed=0, ), - ] + } await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) def _turn_off_light(zone: int) -> None: - device_fixture.light_zones[0].intensity = 0 - device_fixture.light_zones[0].is_on = False + device_fixture.light_zones[1].intensity = 0 + device_fixture.light_zones[1].is_on = False mock_hotspring.turn_off_light.side_effect = _turn_off_light @@ -352,8 +352,8 @@ async def test_disabled_zone_not_added( entity_registry: er.EntityRegistry, ) -> None: """Test disabled light zones are not added to entity registry.""" - device_fixture.light_zones = [ - LightZone( + device_fixture.light_zones = { + 1: LightZone( zone_id=1, is_enabled=False, is_on=False, @@ -362,7 +362,7 @@ async def test_disabled_zone_not_added( intensity=0, loop_speed=0, ), - ] + } await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) assert not entity_registry.async_is_registered(ENTITY_ID) diff --git a/tests/components/intent/test_llm.py b/tests/components/intent/test_llm.py index 8a160e153285b..f87d6fea09288 100644 --- a/tests/components/intent/test_llm.py +++ b/tests/components/intent/test_llm.py @@ -6,10 +6,13 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import llm as intent_llm from homeassistant.components.intent.timers import async_register_timer_handler +from homeassistant.const import SERVICE_TURN_ON from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers import llm +from homeassistant.helpers import intent, llm from homeassistant.setup import async_setup_component +from tests.common import async_mock_service + COVER_ENTITY_ID = "cover.test" @@ -48,6 +51,44 @@ async def test_generic_intents_exposed(hass: HomeAssistant) -> None: assert "intent__HassTurnOff" in names +async def test_turn_on_uses_domain_when_name_blank(hass: HomeAssistant) -> None: + """Test HassTurnOn treats a blank name as omitted and uses the domain.""" + hass.states.async_set("light.test_light", "off", {"friendly_name": "Test Light"}) + async_expose_entity(hass, "conversation", "light.test_light", True) + calls = async_mock_service(hass, "light", SERVICE_TURN_ON) + api = await llm.async_get_api(hass, "assist", _llm_context()) + + await api.async_call_tool( + llm.ToolInput( + "intent__HassTurnOn", + { + "area": "", + "domain": "light", + "floor": " ", + "name": "", + }, + ) + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == {"entity_id": ["light.test_light"]} + + +async def test_turn_on_rejects_all_blank_targets(hass: HomeAssistant) -> None: + """Test HassTurnOn still requires a target after omitting blank values.""" + api = await llm.async_get_api(hass, "assist", _llm_context()) + + with pytest.raises( + intent.IntentHandleError, match="^Service handler cannot target all devices$" + ): + await api.async_call_tool( + llm.ToolInput( + "intent__HassTurnOn", {"area": "", "floor": " ", "name": None} + ) + ) + + async def test_timer_intents_require_timer_device(hass: HomeAssistant) -> None: """Test timer intents are not exposed without a timer-capable device.""" assert "intent__HassStartTimer" not in await _tool_names(hass) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 274e96cf84cd5..9745c6a97be20 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -256,6 +256,57 @@ async def test_onboarding_user_invalid_name( assert resp.status == 400 +@pytest.mark.parametrize( + ("username", "expected_code"), + [ + pytest.param("Test-User", "username_not_normalized", id="uppercase"), + pytest.param("test-user ", "username_not_normalized", id="whitespace"), + pytest.param("existing-user", "username_already_exists", id="duplicate"), + ], +) +async def test_onboarding_user_invalid_username( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client_no_auth: ClientSessionGenerator, + username: str, + expected_code: str, +) -> None: + """Test a rejected username does not leave an orphaned user behind.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + provider = views._async_get_hass_provider(hass) + await provider.async_initialize() + await provider.async_add_auth("existing-user", "test-pass") + + cur_users = len(await hass.auth.async_get_users()) + client = await hass_client_no_auth() + + resp = await client.post( + "/api/onboarding/users", + json={ + "client_id": CLIENT_ID, + "name": "Test Name", + "username": username, + "password": "test-pass", + "language": "en", + }, + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + body = await resp.json() + assert body["code"] == expected_code + # The rejected username is echoed back so the frontend can explain the failure + assert username in body["message"] + + # The step stays open so onboarding can be retried with another username, + # and no user may be left behind from the rejected attempt. + assert const.STEP_USER not in hass_storage[const.DOMAIN]["data"]["done"] + assert len(await hass.auth.async_get_users()) == cur_users + + async def test_onboarding_user_race( hass: HomeAssistant, hass_storage: dict[str, Any], diff --git a/tests/components/pjlink/test_media_player.py b/tests/components/pjlink/test_media_player.py index 0b5c88e93088a..7030fd4c15147 100644 --- a/tests/components/pjlink/test_media_player.py +++ b/tests/components/pjlink/test_media_player.py @@ -2,6 +2,7 @@ from datetime import timedelta import socket +from typing import Any from unittest.mock import MagicMock, create_autospec, patch import pypjlink @@ -25,6 +26,7 @@ Platform, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -322,3 +324,84 @@ async def test_failed_yaml_import( assert issue_registry.async_get_issue( DOMAIN, f"deprecated_yaml_import_issue_{error_str}" ) + + +_ERROR = ProjectorError("projector error") + + +@pytest.mark.parametrize( + ("service", "service_data", "method_name"), + [ + pytest.param("turn_off", {}, "set_power", id="turn_off"), + pytest.param("turn_on", {}, "set_power", id="turn_on"), + pytest.param( + "volume_mute", {"is_volume_muted": True}, "set_mute", id="volume_mute" + ), + pytest.param( + "select_source", {"source": "VGA 1"}, "set_input", id="select_source" + ), + ], +) +async def test_service_command_error_is_home_assistant_error( + mocked_projector: MagicMock, + hass: HomeAssistant, + service: str, + service_data: dict[str, Any], + method_name: str, +) -> None: + """Projector failures during a service command surface as HomeAssistantError.""" + + getattr(mocked_projector, method_name).side_effect = _ERROR + + await setup_pjlink_entry(hass) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + media_player.DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.test", **service_data}, + blocking=True, + ) + + +async def test_service_error_when_projector_unreachable_is_home_assistant_error( + mocked_projector: MagicMock, + projector_from_address: MagicMock, + hass: HomeAssistant, +) -> None: + """A command on an unreachable projector surfaces as HomeAssistantError.""" + + await setup_pjlink_entry(hass) + + projector_from_address.side_effect = socket.timeout + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + media_player.DOMAIN, + "turn_off", + {ATTR_ENTITY_ID: "media_player.test"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "socket_error", [TimeoutError, OSError], ids=["timeout", "os_error"] +) +async def test_service_socket_error_during_command_is_home_assistant_error( + mocked_projector: MagicMock, + hass: HomeAssistant, + socket_error: type[Exception], +) -> None: + """A socket error raised by the command itself surfaces as HomeAssistantError.""" + + mocked_projector.set_power.side_effect = socket_error + + await setup_pjlink_entry(hass) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + media_player.DOMAIN, + "turn_off", + {ATTR_ENTITY_ID: "media_player.test"}, + blocking=True, + ) diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py index e211ff2ef874e..f10bf4b95a463 100644 --- a/tests/components/plex/test_browse_media.py +++ b/tests/components/plex/test_browse_media.py @@ -121,6 +121,7 @@ async def test_browse_media( requests_mock: requests_mock.Mocker, hubs, hubs_music_library, + media_1: str, ) -> None: """Test getting Plex clients from plex.tv.""" websocket_client = await hass_ws_client(hass) @@ -160,6 +161,10 @@ async def test_browse_media( f"{mock_plex_server.url_in_use}/hubs", text=hubs, ) + requests_mock.get( + f"{mock_plex_server.url_in_use}/hubs/home/continueWatching?includeGuids=1", + text=media_1, + ) # Browse into a special folder (server) msg_id += 1 diff --git a/tests/components/select/test_init.py b/tests/components/select/test_init.py index a5be7921fcdd2..cf510165f4114 100644 --- a/tests/components/select/test_init.py +++ b/tests/components/select/test_init.py @@ -31,6 +31,13 @@ class MockSelectEntity(SelectEntity): _attr_options = ["option_one", "option_two", "option_three"] +class MockSelectEntityWithoutOptions(MockSelectEntity): + """Mock SelectEntity without any options to use in tests.""" + + _attr_name = "select without options" + _attr_options: list[str] = [] + + async def test_select(hass: HomeAssistant) -> None: """Test getting data from the mocked select entity.""" select = MockSelectEntity() @@ -202,3 +209,59 @@ async def test_custom_integration_and_validation( blocking=True, ) assert hass.states.get("select.select_2").state == "option 3" + + +@pytest.mark.parametrize( + ("service", "service_data"), + [ + pytest.param(SERVICE_SELECT_FIRST, {}, id="first"), + pytest.param(SERVICE_SELECT_LAST, {}, id="last"), + pytest.param(SERVICE_SELECT_NEXT, {}, id="next_cycle"), + pytest.param(SERVICE_SELECT_NEXT, {ATTR_CYCLE: False}, id="next_no_cycle"), + pytest.param(SERVICE_SELECT_PREVIOUS, {}, id="previous_cycle"), + pytest.param( + SERVICE_SELECT_PREVIOUS, {ATTR_CYCLE: False}, id="previous_no_cycle" + ), + ], +) +@pytest.mark.parametrize( + "current_option", + [None, "option_one"], + ids=["without_current_option", "with_stale_current_option"], +) +async def test_navigation_services_without_options( + hass: HomeAssistant, + service: str, + service_data: dict[str, bool], + current_option: str | None, +) -> None: + """Test the navigation actions on an entity which has no options.""" + entity = MockSelectEntityWithoutOptions() + entity._attr_current_option = current_option + entity.select_option = MagicMock() + setup_test_component_platform(hass, DOMAIN, [entity]) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert hass.states.get("select.select_without_options").state == STATE_UNKNOWN + + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: "select.select_without_options"} | service_data, + blocking=True, + ) + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == "no_options" + assert exc.value.translation_placeholders == { + "entity_id": "select.select_without_options" + } + assert ( + str(exc.value) + == "Entity select.select_without_options has no options to select from" + ) + + entity.select_option.assert_not_called() + assert hass.states.get("select.select_without_options").state == STATE_UNKNOWN diff --git a/tests/components/shelly/test_camera.py b/tests/components/shelly/test_camera.py index fd49c646c309f..bfd839f0e1498 100644 --- a/tests/components/shelly/test_camera.py +++ b/tests/components/shelly/test_camera.py @@ -2,9 +2,15 @@ from collections.abc import Generator from copy import deepcopy -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from aioshelly.const import MODEL_CAMERA +from aioshelly.exceptions import ( + DeviceConnectionError, + DeviceConnectionTimeoutError, + HttpCallError, + InvalidAuthError, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -14,7 +20,8 @@ CameraState, get_camera_from_entity_id, ) -from homeassistant.components.shelly.const import CONF_SLEEP_PERIOD +from homeassistant.components.shelly.const import CONF_SLEEP_PERIOD, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_MODEL, @@ -23,6 +30,7 @@ Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_registry import EntityRegistry from . import MOCK_MAC, init_integration, patch_platforms, register_entity @@ -97,11 +105,85 @@ async def test_camera_use_stream_for_stills( hass: HomeAssistant, mock_camera_rpc_device: Mock, ) -> None: - """Test use_stream_for_stills returns True (still images from the RTSP stream).""" + """Test use_stream_for_stills returns False (still images from HTTP snapshot).""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + assert camera.use_stream_for_stills is False + + +async def test_camera_image( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, +) -> None: + """Test async_camera_image returns snapshot from the device.""" await init_integration(hass, 3, model=MODEL_CAMERA) camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) - assert camera.use_stream_for_stills is True + mock_camera_rpc_device.camera_get_image = AsyncMock(return_value=b"fake-image") + + assert await camera.async_camera_image() == b"fake-image" + assert mock_camera_rpc_device.camera_get_image.call_count == 1 + assert mock_camera_rpc_device.camera_get_image.call_args[0] == (0,) + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionTimeoutError, + "Device communication error occurred for Test name", + ), + ( + DeviceConnectionError, + "Device communication error occurred for Test name", + ), + ( + HttpCallError(500, "Server error"), + "HTTP call error occurred for Test name", + ), + ], +) +async def test_camera_image_exc( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + exception: Exception, + error: str, +) -> None: + """Test camera snapshot with exception.""" + await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + mock_camera_rpc_device.camera_get_image = AsyncMock(side_effect=exception) + + with pytest.raises(HomeAssistantError, match=error): + await camera.async_camera_image() + + +async def test_camera_image_reauth_error( + hass: HomeAssistant, mock_camera_rpc_device: Mock +) -> None: + """Test camera snapshot with authentication error starts reauth.""" + entry = await init_integration(hass, 3, model=MODEL_CAMERA) + + camera = get_camera_from_entity_id(hass, CAMERA_ENTITY_ID) + mock_camera_rpc_device.camera_get_image = AsyncMock( + side_effect=InvalidAuthError(401) + ) + + assert await camera.async_camera_image() is None + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id async def test_camera_stream_source( diff --git a/tests/components/tradfri/common.py b/tests/components/tradfri/common.py index ab3f6fb71c18e..474a894a7ce3e 100644 --- a/tests/components/tradfri/common.py +++ b/tests/components/tradfri/common.py @@ -1,6 +1,5 @@ """Common tools used for the Tradfri test suite.""" -from copy import deepcopy from dataclasses import dataclass from typing import Any @@ -62,7 +61,7 @@ async def trigger_observe_callback( assert observe_command device_path = "/".join(str(v) for v in device.path) - device_state = deepcopy(device.raw) + device_state = device.raw.dict(by_alias=True) # Create a default observed state based on the sent commands. for command in self.sent_commands: diff --git a/tests/components/tradfri/conftest.py b/tests/components/tradfri/conftest.py index 6b9c2ab77c3d2..7a45adba24796 100644 --- a/tests/components/tradfri/conftest.py +++ b/tests/components/tradfri/conftest.py @@ -7,13 +7,12 @@ import pytest from pytradfri.command import Command -from pytradfri.const import ATTR_FIRMWARE_VERSION, ATTR_GATEWAY_ID from pytradfri.device import Device from pytradfri.gateway import Gateway from homeassistant.components.tradfri.const import DOMAIN -from . import GATEWAY_ID, TRADFRI_PATH +from . import TRADFRI_PATH from .common import CommandStore from tests.common import load_fixture @@ -28,12 +27,12 @@ def mock_entry_setup() -> Generator[AsyncMock]: @pytest.fixture(name="mock_gateway", autouse=True) -def mock_gateway_fixture(command_store: CommandStore) -> Gateway: +def mock_gateway_fixture(command_store: CommandStore, gateway_response: str) -> Gateway: """Mock a Tradfri gateway.""" gateway = Gateway() command_store.register_response( gateway.get_gateway_info(), - {ATTR_GATEWAY_ID: GATEWAY_ID, ATTR_FIRMWARE_VERSION: "1.2.1234"}, + json.loads(gateway_response), ) command_store.register_response( gateway.get_devices(), @@ -90,10 +89,16 @@ def device( """Return a device.""" device_response: dict[str, Any] = json.loads(request.getfixturevalue(request.param)) device = Device(device_response) - command_store.register_device(mock_gateway, device.raw) + command_store.register_device(mock_gateway, device_response) return device +@pytest.fixture(scope="package") +def gateway_response() -> str: + """Return a gateway response.""" + return load_fixture("gateway.json", DOMAIN) + + @pytest.fixture(scope="package") def air_purifier() -> str: """Return an air purifier response.""" diff --git a/tests/components/tradfri/fixtures/gateway.json b/tests/components/tradfri/fixtures/gateway.json new file mode 100644 index 0000000000000..e72e95eff10d6 --- /dev/null +++ b/tests/components/tradfri/fixtures/gateway.json @@ -0,0 +1,44 @@ +{ + "9023": "xyz.pool.ntp.pool", + "9029": "1.2.1234", + "9054": 0, + "9055": 0, + "9059": 1509788799, + "9060": "2017-11-04T09:46:39.046784Z", + "9061": 0, + "9062": 0, + "9066": 5, + "9069": 1509474847, + "9071": 1, + "9072": 0, + "9073": 0, + "9074": 0, + "9075": 0, + "9076": 0, + "9077": 0, + "9078": 0, + "9079": 0, + "9080": 0, + "9081": "mock-gateway-id", + "9082": true, + "9083": "123-45-67", + "9092": 0, + "9093": 0, + "9103": "blablablabla12.iot.eu-central-1.amazonaws.com", + "9105": 0, + "9106": 0, + "9107": 0, + "9118": 0, + "9200": "abc12345-a123-b345-c567-123abc123456", + "9201": 1, + "9202": 1234567890, + "9204": 1, + "9208": 1234567890, + "9209": 1234567890, + "9211": 0, + "9232": 1234567, + "9234": 1, + "9235": "SE", + "9236": 3600, + "9266": 2 +} diff --git a/tests/components/tradfri/test_init.py b/tests/components/tradfri/test_init.py index 3b89ed1212498..66009d8974c68 100644 --- a/tests/components/tradfri/test_init.py +++ b/tests/components/tradfri/test_init.py @@ -1,8 +1,9 @@ """Tests for Tradfri setup.""" +import json from unittest.mock import MagicMock -from pytradfri.const import ATTR_FIRMWARE_VERSION, ATTR_GATEWAY_ID +from pytradfri.const import ATTR_GATEWAY_ID from pytradfri.gateway import Gateway from homeassistant.components import tradfri @@ -104,6 +105,7 @@ async def test_migrate_config_entry_and_identifiers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, command_store: CommandStore, + gateway_response: str, ) -> None: """Test migration of device registry identifiers to the unique format. @@ -120,7 +122,7 @@ async def test_migrate_config_entry_and_identifiers( }, ) - gateway1 = mock_gateway_fixture(command_store, GATEWAY_ID1) + gateway1 = mock_gateway_fixture(command_store, GATEWAY_ID1, gateway_response) command_store.register_device( gateway1, await async_load_json_object_fixture(hass, "bulb_w.json", DOMAIN) ) @@ -226,12 +228,16 @@ async def test_migrate_config_entry_and_identifiers( assert config_entry3.version == 1 -def mock_gateway_fixture(command_store: CommandStore, gateway_id: str) -> Gateway: +def mock_gateway_fixture( + command_store: CommandStore, gateway_id: str, gateway_response: str +) -> Gateway: """Mock a Tradfri gateway.""" gateway = Gateway() + gateway_info_response = json.loads(gateway_response) + gateway_info_response[ATTR_GATEWAY_ID] = gateway_id command_store.register_response( gateway.get_gateway_info(), - {ATTR_GATEWAY_ID: gateway_id, ATTR_FIRMWARE_VERSION: "1.2.1234"}, + gateway_info_response, ) command_store.register_response( gateway.get_devices(), diff --git a/tests/components/tradfri/test_light.py b/tests/components/tradfri/test_light.py index 5f6ce41e1760b..43f3b436f5e1c 100644 --- a/tests/components/tradfri/test_light.py +++ b/tests/components/tradfri/test_light.py @@ -245,10 +245,16 @@ async def test_turn_on( state_attributes: dict[str, Any], ) -> None: """Test turning on a light.""" - # Make sure the light is off. - device.raw[ATTR_LIGHT_CONTROL][0][ATTR_DEVICE_STATE] = 0 await setup_integration(hass) + await command_store.trigger_observe_callback( + hass, device, {ATTR_LIGHT_CONTROL: [{ATTR_DEVICE_STATE: 0}]} + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index 62fcc12a9abcd..c66101c894b60 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -11,7 +11,9 @@ DeviceType, VizioAuthError, VizioConnectionError, + VizioError, VizioNotFoundError, + VizioUnsupportedError, ) from vizaio.profiles import SOUNDBAR_PROFILE @@ -228,18 +230,18 @@ async def test_state_extended_polling( @pytest.mark.usefixtures("vizio_connect") -async def test_soundbar_does_not_poll_state_extended( +async def test_soundbar_state_extended_auth_failure_falls_back( hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry, mock_vizio: AsyncMock, ) -> None: - """Test soundbars use the unauthenticated power endpoint.""" + """Test soundbars fall back when state_extended rejects no token.""" mock_vizio.profile = SOUNDBAR_PROFILE mock_vizio.get_state_extended.side_effect = VizioAuthError("token required") await setup_integration(hass, mock_speaker_config_entry) - mock_vizio.get_state_extended.assert_not_called() + mock_vizio.get_state_extended.assert_called_once() mock_vizio.get_power_state.assert_called_once() assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) @@ -259,15 +261,23 @@ async def test_state_extended_power_off( mock_vizio.get_settings.assert_not_called() +@pytest.mark.parametrize( + "error", + [ + pytest.param(VizioNotFoundError("not found"), id="not_found"), + pytest.param(VizioUnsupportedError("not supported"), id="unsupported"), + ], +) @pytest.mark.usefixtures("vizio_connect") async def test_state_extended_probed_only_once( hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry, mock_vizio: AsyncMock, freezer: FrozenDateTimeFactory, + error: VizioError, ) -> None: - """Test firmware without state_extended is not re-probed every refresh.""" - mock_vizio.get_state_extended.side_effect = VizioNotFoundError("not supported") + """Test unavailable state_extended is not re-probed every refresh.""" + mock_vizio.get_state_extended.side_effect = error await setup_integration(hass, mock_tv_config_entry) mock_vizio.get_state_extended.reset_mock() diff --git a/tests/components/waterfurnace/conftest.py b/tests/components/waterfurnace/conftest.py index a174872082c1e..ee9949846795f 100644 --- a/tests/components/waterfurnace/conftest.py +++ b/tests/components/waterfurnace/conftest.py @@ -69,7 +69,6 @@ def mock_waterfurnace_client() -> Generator[Mock]: device_data = WFReading(load_json_object_fixture("device_data.json", DOMAIN)) client.read.return_value = device_data - client.read_with_retry.return_value = device_data client.get_energy_data.side_effect = WFNoDataError("No data") yield client @@ -108,7 +107,6 @@ def mock_waterfurnace_client_multi_device() -> Generator[Mock]: client.account_id = "test_account_id" client.devices = [WFGateway(gateway_data_1), WFGateway(gateway_data_2)] client.read.return_value = device_data - client.read_with_retry.return_value = device_data client.get_energy_data.side_effect = WFNoDataError("No data") instances.append(client) diff --git a/tests/components/waterfurnace/test_climate.py b/tests/components/waterfurnace/test_climate.py index c845e13ec5906..4700a87c27233 100644 --- a/tests/components/waterfurnace/test_climate.py +++ b/tests/components/waterfurnace/test_climate.py @@ -67,7 +67,7 @@ async def test_hvac_mode_mapping( expected_hvac_mode: HVACMode, ) -> None: """Test that ActiveSettings.mode maps to the correct HVACMode.""" - mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = ( + mock_waterfurnace_client.read.return_value.activesettings.activemode = ( active_mode_index ) @@ -117,7 +117,7 @@ async def test_hvac_action_mapping( expected_action: HVACAction, ) -> None: """Test that WFReading.mode maps to the correct HVACAction.""" - mock_waterfurnace_client.read_with_retry.return_value.modeofoperation = mode_index + mock_waterfurnace_client.read.return_value.modeofoperation = mode_index freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -182,7 +182,7 @@ async def test_set_temperature_single_cool( ) -> None: """Test setting temperature in cool mode sets cooling setpoint.""" # Switch to Cool mode - mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 2 + mock_waterfurnace_client.read.return_value.activesettings.activemode = 2 freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -208,7 +208,7 @@ async def test_set_temperature_range( ) -> None: """Test setting temperature range sets both setpoints.""" # Switch to Auto mode - mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 1 + mock_waterfurnace_client.read.return_value.activesettings.activemode = 1 freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -254,7 +254,7 @@ async def test_target_temperature_cool_mode( freezer: FrozenDateTimeFactory, ) -> None: """Test target_temperature returns cooling setpoint in cool mode.""" - mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 2 + mock_waterfurnace_client.read.return_value.activesettings.activemode = 2 freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -272,7 +272,7 @@ async def test_target_temperature_range_auto_mode( freezer: FrozenDateTimeFactory, ) -> None: """Test target_temperature_high/low in auto mode.""" - mock_waterfurnace_client.read_with_retry.return_value.activesettings.activemode = 1 + mock_waterfurnace_client.read.return_value.activesettings.activemode = 1 freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/waterfurnace/test_init.py b/tests/components/waterfurnace/test_init.py index c7424c493358e..2c00ee4dc721f 100644 --- a/tests/components/waterfurnace/test_init.py +++ b/tests/components/waterfurnace/test_init.py @@ -148,7 +148,7 @@ async def test_setup_creates_energy_coordinator( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_waterfurnace_client.login.call_count == 2 - assert mock_waterfurnace_client.read_with_retry.call_count == 1 + assert mock_waterfurnace_client.read.call_count == 1 assert mock_waterfurnace_client.get_energy_data.call_count == 1 diff --git a/tests/components/waterfurnace/test_sensor.py b/tests/components/waterfurnace/test_sensor.py index 0d2248f07a3c0..5a40e34dd9f96 100644 --- a/tests/components/waterfurnace/test_sensor.py +++ b/tests/components/waterfurnace/test_sensor.py @@ -44,7 +44,7 @@ async def test_sensor( assert state assert state.state == "1500" - mock_waterfurnace_client.read_with_retry.return_value.totalunitpower = 2000 + mock_waterfurnace_client.read.return_value.totalunitpower = 2000 freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -75,7 +75,7 @@ async def test_availability( assert state assert state.state == "1500" - mock_waterfurnace_client.read_with_retry.side_effect = side_effect + mock_waterfurnace_client.read.side_effect = side_effect freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -84,7 +84,7 @@ async def test_availability( assert state assert state.state == STATE_UNAVAILABLE - mock_waterfurnace_client.read_with_retry.side_effect = None + mock_waterfurnace_client.read.side_effect = None freezer.tick(UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index c164e939cc69b..1c0d5226bded9 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -42,6 +42,23 @@ def slot_schema(self): return self._mock_slot_schema +@pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param(None, True, id="none"), + pytest.param("", True, id="empty-string"), + pytest.param(" \t", True, id="whitespace-string"), + pytest.param(0, False, id="zero"), + pytest.param(False, False, id="false"), + pytest.param([], False, id="empty-list"), + pytest.param({}, False, id="empty-dict"), + ], +) +def test_is_blank_slot_value(value: object, expected: bool) -> None: + """Test identifying intent slot values that represent an unspecified slot.""" + assert intent.is_blank_slot_value(value) is expected + + async def test_async_match_states( hass: HomeAssistant, area_registry: ar.AreaRegistry, diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 9dcf61bb5340a..b39a68ed89f0d 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1,7 +1,7 @@ """Tests for the llm helpers.""" from decimal import Decimal -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import probatio import pytest @@ -143,6 +143,75 @@ async def test_call_tool_no_existing( ) +async def test_call_non_intent_tool_preserves_blank_arguments( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test blank arguments are preserved for non-intent tools.""" + tool_args = {"name": "", "response": " ", "other": None} + tool = MagicMock(spec=llm.Tool) + tool.name = "test_tool" + tool.async_call = AsyncMock(return_value={"tool_args": tool_args}) + instance = llm.APIInstance( + MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] + ) + + result = await instance.async_call_tool(llm.ToolInput(tool.name, tool_args)) + + assert result == {"tool_args": tool_args} + assert tool.async_call.await_args.args[1].tool_args is tool_args + + +@pytest.mark.parametrize("namespaced", [False, True]) +async def test_intent_tool_omits_blank_arguments( + hass: HomeAssistant, llm_context: llm.LLMContext, namespaced: bool +) -> None: + """Test direct and namespaced intent tools omit blank arguments.""" + + class MyIntentHandler(intent.IntentHandler): + intent_type = "test_intent" + slot_schema = { + probatio.Optional("name"): intent.non_empty_string, + probatio.Optional("response"): cv.string, + probatio.Optional("count"): probatio.Coerce(int), + probatio.Optional("enabled"): cv.boolean, + } + + intent_tool = llm.IntentTool("test_intent", MyIntentHandler()) + tool: llm.Tool = ( + llm.NamespacedTool("test_api", intent_tool) if namespaced else intent_tool + ) + tool_args = { + "name": "", + "response": " \t", + "other": None, + "count": 0, + "enabled": False, + } + tool_input = llm.ToolInput(tool.name, tool_args) + instance = llm.APIInstance( + MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] + ) + intent_response = intent.IntentResponse("*") + + with patch( + "homeassistant.helpers.intent.async_handle", return_value=intent_response + ) as mock_intent_handle: + await instance.async_call_tool(tool_input) + + assert mock_intent_handle.await_args.kwargs["slots"] == { + "count": {"value": 0}, + "enabled": {"value": False}, + } + assert tool_input.tool_args is tool_args + assert tool_args == { + "name": "", + "response": " \t", + "other": None, + "count": 0, + "enabled": False, + } + + async def test_assist_api( hass: HomeAssistant, device_registry: dr.DeviceRegistry,