diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f6cb56594c64d4..b5d5e85ab093ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.6 + rev: v0.16.7 hooks: - id: ruff-check args: diff --git a/CODEOWNERS b/CODEOWNERS index 444ba17de98097..099f990624cf39 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2059,6 +2059,8 @@ CLAUDE.md @home-assistant/core /tests/components/vodafone_station/ @paoloantinori @chemelli74 /homeassistant/components/voip/ @synesthesiam @jaminh /tests/components/voip/ @synesthesiam @jaminh +/homeassistant/components/volkszaehler/ @StefanSchoof +/tests/components/volkszaehler/ @StefanSchoof /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvo/ @thomasddn diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index dfe0a3b7a2b6a8..aa24c264824b24 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -2,7 +2,11 @@ import logging -from cookidoo_api import CookidooAuthException, CookidooRequestException +from cookidoo_api import ( + CookidooAuthException, + CookidooParseException, + CookidooRequestException, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -98,7 +102,11 @@ async def async_migrate_entry( try: await cookidoo.login() user_info = await cookidoo.get_user_info() - except (CookidooRequestException, CookidooAuthException) as e: + except ( + CookidooAuthException, + CookidooParseException, + CookidooRequestException, + ) as e: _LOGGER.error("Could not migrate config entry: %s", e) return False @@ -114,7 +122,11 @@ async def async_migrate_entry( try: await cookidoo.login() user_info = await cookidoo.get_user_info() - except (CookidooRequestException, CookidooAuthException) as e: + except ( + CookidooAuthException, + CookidooParseException, + CookidooRequestException, + ) as e: _LOGGER.error("Could not migrate config entry: %s", e) return False diff --git a/homeassistant/components/cookidoo/calendar.py b/homeassistant/components/cookidoo/calendar.py index 9d4903f7b26e94..302a7e9591d5e2 100644 --- a/homeassistant/components/cookidoo/calendar.py +++ b/homeassistant/components/cookidoo/calendar.py @@ -4,11 +4,7 @@ import logging from typing import override -from cookidoo_api import ( - CookidooAuthException, - CookidooException, - CookidooRequestException, -) +from cookidoo_api import CookidooAuthException, CookidooException from cookidoo_api.types import CookidooCalendarDayRecipe from homeassistant.components.calendar import CalendarEntity, CalendarEvent @@ -82,14 +78,14 @@ async def _fetch_week_plan(self, week_day: date) -> list: except CookidooAuthException: try: await self.coordinator.cookidoo.login() - except (CookidooAuthException, CookidooRequestException) as exc: + return await self.coordinator.cookidoo.get_recipes_in_calendar_week( + week_day + ) + except CookidooException as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="calendar_fetch_failed", ) from exc - return await self.coordinator.cookidoo.get_recipes_in_calendar_week( - week_day - ) except CookidooException as e: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py index 569d04810c40f0..373d34013c38a8 100644 --- a/homeassistant/components/cookidoo/config_flow.py +++ b/homeassistant/components/cookidoo/config_flow.py @@ -1,11 +1,14 @@ """Config flow for Cookidoo integration.""" from collections.abc import Mapping +from dataclasses import asdict import logging from typing import Any, override from cookidoo_api import ( + CookidooAuthData, CookidooAuthException, + CookidooParseException, CookidooRequestException, get_country_options, get_localization_options, @@ -18,7 +21,14 @@ ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + CONF_TOKEN, +) +from homeassistant.core import callback from homeassistant.helpers.selector import ( CountrySelector, CountrySelectorConfig, @@ -61,6 +71,9 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN): user_input: dict[str, Any] user_uuid: str + # A login whose token response carries no refresh token leaves the library + # with nothing to hand us, and the entry is then created without tokens + token: dict[str, Any] = {} async def async_step_reconfigure( self, user_input: dict[str, Any] @@ -119,7 +132,12 @@ async def async_step_language( ): if self.source == SOURCE_USER: return self.async_create_entry( - title="Cookidoo", data={**self.user_input, **language_input} + title="Cookidoo", + data={ + **self.user_input, + **language_input, + CONF_TOKEN: self.token, + }, ) reconfigure_entry = self._get_reconfigure_entry() return self.async_update_reload_and_abort( @@ -128,6 +146,7 @@ async def async_step_language( **reconfigure_entry.data, **self.user_input, **language_input, + CONF_TOKEN: self.token, }, ) @@ -160,7 +179,7 @@ async def async_step_reauth_confirm( await self.async_set_unique_id(self.user_uuid) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( - reauth_entry, data_updates=user_input + reauth_entry, data_updates={**user_input, CONF_TOKEN: self.token} ) return self.async_show_form( step_id="reauth_confirm", @@ -200,6 +219,11 @@ async def generate_language_schema(self) -> None: ), } + @callback + def _save_token(self, auth_data: CookidooAuthData) -> None: + """Keep the tokens the library hands us during the validation requests.""" + self.token = asdict(auth_data) + async def validate_input( self, user_input: dict[str, Any], @@ -222,14 +246,21 @@ async def validate_input( await get_localization_options(country=data_input[CONF_COUNTRY].lower()) )[0].language # Pick any language to test login - cookidoo = await cookidoo_from_config_data(self.hass, data_input) + # Only this attempt's tokens may reach the entry: a login that yields + # none leaves _save_token uncalled, and an earlier attempt may have + # stored a pair, for another account in a reauth + self.token = {} + cookidoo = await cookidoo_from_config_data( + self.hass, data_input, on_auth_data_update=self._save_token + ) try: await cookidoo.login() user_info = await cookidoo.get_user_info() self.user_uuid = user_info.id if language_input: await cookidoo.get_additional_items() - except CookidooRequestException: + except CookidooRequestException, CookidooParseException: + # login() scrapes the CIAM login page, so it can also fail to parse it errors["base"] = "cannot_connect" except CookidooAuthException: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index 7f1e947c584a41..24e5f54413be21 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -11,6 +11,7 @@ CookidooAuthException, CookidooException, CookidooIngredientItem, + CookidooParseException, CookidooRequestException, CookidooSubscription, CookidooUserInfo, @@ -60,11 +61,20 @@ def __init__( ) self.cookidoo = cookidoo + async def _async_login(self) -> CookidooUserInfo: + """Return the user info, reusing the persisted tokens while they are valid.""" + if self.cookidoo.auth_data is not None: + try: + return await self.cookidoo.get_user_info() + except CookidooAuthException: + _LOGGER.debug("Stored tokens are no longer valid, logging in again") + await self.cookidoo.login() + return await self.cookidoo.get_user_info() + @override async def _async_setup(self) -> None: try: - await self.cookidoo.login() - self.user = await self.cookidoo.get_user_info() + self.user = await self._async_login() except CookidooRequestException as e: raise UpdateFailed( translation_domain=DOMAIN, @@ -78,6 +88,12 @@ async def _async_setup(self) -> None: CONF_EMAIL: self.config_entry.data[CONF_EMAIL] }, ) from e + except CookidooParseException as e: + # login() scrapes the CIAM login page, so it can also fail to parse it + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e @override async def _async_update_data(self) -> CookidooData: @@ -99,7 +115,7 @@ async def _async_update_data(self) -> CookidooData: CONF_EMAIL: self.config_entry.data[CONF_EMAIL] }, ) from exc - except CookidooRequestException as exc: + except (CookidooRequestException, CookidooParseException) as exc: raise UpdateFailed( translation_domain=DOMAIN, translation_key="setup_request_exception", diff --git a/homeassistant/components/cookidoo/diagnostics.py b/homeassistant/components/cookidoo/diagnostics.py index f981317df1968c..d3d059ffe31b56 100644 --- a/homeassistant/components/cookidoo/diagnostics.py +++ b/homeassistant/components/cookidoo/diagnostics.py @@ -4,13 +4,14 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant from .coordinator import CookidooConfigEntry TO_REDACT = [ CONF_PASSWORD, + CONF_TOKEN, ] diff --git a/homeassistant/components/cookidoo/helpers.py b/homeassistant/components/cookidoo/helpers.py index da11bf0784a967..3bda8a3917d6d4 100644 --- a/homeassistant/components/cookidoo/helpers.py +++ b/homeassistant/components/cookidoo/helpers.py @@ -1,19 +1,34 @@ """Helpers for cookidoo.""" +from collections.abc import Callable +from dataclasses import asdict from typing import Any from aiohttp import CookieJar -from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options +from cookidoo_api import ( + Cookidoo, + CookidooAuthData, + CookidooConfig, + get_localization_options, +) -from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + CONF_TOKEN, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import CookidooConfigEntry async def cookidoo_from_config_data( - hass: HomeAssistant, data: dict[str, Any] + hass: HomeAssistant, + data: dict[str, Any], + on_auth_data_update: Callable[[CookidooAuthData], None] | None = None, ) -> Cookidoo: """Build cookidoo from config data.""" localizations = await get_localization_options( @@ -28,6 +43,7 @@ async def cookidoo_from_config_data( password=data[CONF_PASSWORD], localization=localizations[0], ), + on_auth_data_update=on_auth_data_update, ) @@ -35,4 +51,17 @@ async def cookidoo_from_config_entry( hass: HomeAssistant, entry: CookidooConfigEntry ) -> Cookidoo: """Build cookidoo from config entry.""" - return await cookidoo_from_config_data(hass, dict(entry.data)) + + @callback + def save_auth_data(auth_data: CookidooAuthData) -> None: + """Store the tokens, so a restart does not need a new login.""" + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_TOKEN: asdict(auth_data)} + ) + + cookidoo = await cookidoo_from_config_data( + hass, dict(entry.data), on_auth_data_update=save_auth_data + ) + if token := entry.data.get(CONF_TOKEN): + cookidoo.apply_auth_data(CookidooAuthData(**token)) + return cookidoo diff --git a/homeassistant/components/cookidoo/manifest.json b/homeassistant/components/cookidoo/manifest.json index 015b01c834eb06..943559bf7db2a6 100644 --- a/homeassistant/components/cookidoo/manifest.json +++ b/homeassistant/components/cookidoo/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["cookidoo_api"], "quality_scale": "silver", - "requirements": ["cookidoo-api==0.17.2"] + "requirements": ["cookidoo-api==0.18.4"] } diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 8e0bb4e247b6f9..660ec8abf5736c 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -237,6 +237,11 @@ async def async_step_init( except AbortFlow as err: errors = {"base": err.reason} else: + if ( + CONF_CUSTOM_EVENT_DATA_TEMPLATE not in user_input + and CONF_CUSTOM_EVENT_DATA_TEMPLATE in entry_data + ): + entry_data.pop(CONF_CUSTOM_EVENT_DATA_TEMPLATE) entry_data.update(user_input) errors = await validate_input(self.hass, entry_data) if not errors: diff --git a/homeassistant/components/midea/binary_sensor.py b/homeassistant/components/midea/binary_sensor.py index 5b85c13e5efa7d..16978cce52ca44 100644 --- a/homeassistant/components/midea/binary_sensor.py +++ b/homeassistant/components/midea/binary_sensor.py @@ -87,6 +87,7 @@ key="arofene_link", translation_key="arofene_link", device_class=BinarySensorDeviceClass.PLUG, + entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensorEntityDescription( key="header_exist", diff --git a/homeassistant/components/midea/number.py b/homeassistant/components/midea/number.py index 7524db1171dcc1..275c9bf4c1fb20 100644 --- a/homeassistant/components/midea/number.py +++ b/homeassistant/components/midea/number.py @@ -13,7 +13,7 @@ NumberEntity, NumberEntityDescription, ) -from homeassistant.const import UnitOfMass, UnitOfTime, UnitOfVolume +from homeassistant.const import EntityCategory, UnitOfMass, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -64,6 +64,7 @@ class MideaNumberEntityDescription(NumberEntityDescription): native_max_value=360, native_step=1, native_unit_of_measurement=UnitOfTime.DAYS, + entity_category=EntityCategory.CONFIG, ), MideaNumberEntityDescription( key="water_hardness", @@ -72,6 +73,7 @@ class MideaNumberEntityDescription(NumberEntityDescription): native_min_value=0, native_max_value=65535, native_step=1, + entity_category=EntityCategory.CONFIG, ), MideaNumberEntityDescription( key="flushing_days", @@ -82,6 +84,7 @@ class MideaNumberEntityDescription(NumberEntityDescription): native_max_value=99, native_step=1, native_unit_of_measurement=UnitOfTime.DAYS, + entity_category=EntityCategory.CONFIG, ), MideaNumberEntityDescription( key="leak_water_protection_value", @@ -92,6 +95,7 @@ class MideaNumberEntityDescription(NumberEntityDescription): native_max_value=2550, native_step=50, native_unit_of_measurement=UnitOfVolume.LITERS, + entity_category=EntityCategory.CONFIG, ), MideaNumberEntityDescription( key="salt_setting", diff --git a/homeassistant/components/midea/quality_scale.yaml b/homeassistant/components/midea/quality_scale.yaml index 31475a44aa7a9d..e04f2bd1e71670 100644 --- a/homeassistant/components/midea/quality_scale.yaml +++ b/homeassistant/components/midea/quality_scale.yaml @@ -69,7 +69,7 @@ rules: dynamic-devices: status: exempt comment: One config entry per physical device, no hub. - entity-category: todo + entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done diff --git a/homeassistant/components/midea/select.py b/homeassistant/components/midea/select.py index 1e0ae630c38a32..5dd1ed61014816 100644 --- a/homeassistant/components/midea/select.py +++ b/homeassistant/components/midea/select.py @@ -6,6 +6,7 @@ from midealocal.const import DeviceType from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -106,6 +107,7 @@ class MideaSelectEntityDescription(SelectEntityDescription): translation_key="screen_display", models=[DeviceType.FC, DeviceType.FD], options_attribute="screen_displays", + entity_category=EntityCategory.CONFIG, ), ] diff --git a/homeassistant/components/midea/switch.py b/homeassistant/components/midea/switch.py index 7ec118b048c6d4..c2e62326fa79bf 100644 --- a/homeassistant/components/midea/switch.py +++ b/homeassistant/components/midea/switch.py @@ -6,6 +6,7 @@ from midealocal.const import DeviceType from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -28,11 +29,13 @@ class MideaSwitchEntityDescription(SwitchEntityDescription): translation_key="pump", models=[DeviceType.A1], capability="pump", + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="aux_heating", translation_key="aux_heating", models=[DeviceType.AC, DeviceType.CC, DeviceType.CF], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="child_lock", @@ -48,36 +51,43 @@ class MideaSwitchEntityDescription(SwitchEntityDescription): DeviceType.FB, DeviceType.FC, ], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="prompt_tone", translation_key="prompt_tone", models=[DeviceType.AC], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="screen_display", translation_key="screen_display", models=[DeviceType.AC], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="out_silent", translation_key="out_silent", models=[DeviceType.AC], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="smart_eye", translation_key="smart_eye", models=[DeviceType.AC], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="anion", translation_key="anion", models=[DeviceType.AC], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="sound", translation_key="sound", models=[DeviceType.AC], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="self_clean", @@ -88,26 +98,31 @@ class MideaSwitchEntityDescription(SwitchEntityDescription): key="disinfect", translation_key="disinfect", models=[DeviceType.C3], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="tbh", translation_key="tbh", models=[DeviceType.C3], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="zone1_curve", translation_key="zone1_curve", models=[DeviceType.C3], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="zone2_curve", translation_key="zone2_curve", models=[DeviceType.C3], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="night_light", translation_key="night_light", models=[DeviceType.CC], + entity_category=EntityCategory.CONFIG, ), MideaSwitchEntityDescription( key="ai_switch", diff --git a/homeassistant/components/midea/time.py b/homeassistant/components/midea/time.py index 5bdb39517b2fa6..1a912a4905bf27 100644 --- a/homeassistant/components/midea/time.py +++ b/homeassistant/components/midea/time.py @@ -4,6 +4,7 @@ from typing import cast, override from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -14,7 +15,9 @@ TIMES: list[TimeEntityDescription] = [ TimeEntityDescription( - key="timing_regeneration", translation_key="timing_regeneration" + key="timing_regeneration", + translation_key="timing_regeneration", + entity_category=EntityCategory.CONFIG, ) ] diff --git a/homeassistant/components/mitsubishi_comfort/manifest.json b/homeassistant/components/mitsubishi_comfort/manifest.json index 4218b0a806de5d..2538ecaebe00a8 100644 --- a/homeassistant/components/mitsubishi_comfort/manifest.json +++ b/homeassistant/components/mitsubishi_comfort/manifest.json @@ -9,5 +9,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["mitsubishi-comfort==0.5.2"] + "requirements": ["mitsubishi-comfort==0.6.0"] } diff --git a/homeassistant/components/monzo/sensor.py b/homeassistant/components/monzo/sensor.py index e67e8490c45e0f..b0d526f58da5cc 100644 --- a/homeassistant/components/monzo/sensor.py +++ b/homeassistant/components/monzo/sensor.py @@ -8,6 +8,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -35,6 +36,7 @@ class MonzoSensorEntityDescription(SensorEntityDescription): value_fn=lambda data: data["balance"]["balance"] / 100, device_class=SensorDeviceClass.MONETARY, suggested_display_precision=2, + state_class=SensorStateClass.TOTAL, ), MonzoSensorEntityDescription( key="total_balance", @@ -42,6 +44,7 @@ class MonzoSensorEntityDescription(SensorEntityDescription): value_fn=lambda data: data["balance"]["total_balance"] / 100, device_class=SensorDeviceClass.MONETARY, suggested_display_precision=2, + state_class=SensorStateClass.TOTAL, ), MonzoSensorEntityDescription( key="spend_today", @@ -59,6 +62,7 @@ class MonzoSensorEntityDescription(SensorEntityDescription): value_fn=lambda data: data["balance"] / 100, device_class=SensorDeviceClass.MONETARY, suggested_display_precision=2, + state_class=SensorStateClass.TOTAL, ), ) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 7d3ed3ab53e4db..75e68b7b159635 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -482,7 +482,7 @@ def native_value(self): if self.entity_description.key == "volume_size_used": return used_gb - if self.entity_description.key == "volume_percentage_used": + if self.entity_description.key == "volume_percentage_used" and total_gb != 0: return used_gb / total_gb * 100 return None diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index dd9ad0ea75481b..e9e4bd8ab4fad4 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -36,27 +36,35 @@ class ReolinkCameraEntityDescription( key="sub", stream="sub", translation_key="sub", - supported=lambda api, ch: api.supported(ch, "stream"), + supported=lambda api, ch: ( + api.supported(ch, "stream") and api.supported(ch, "sub") + ), ), ReolinkCameraEntityDescription( key="main", stream="main", translation_key="main", - supported=lambda api, ch: api.supported(ch, "stream"), + supported=lambda api, ch: ( + api.supported(ch, "stream") and api.supported(ch, "main") + ), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="snapshots_sub", stream="snapshots_sub", translation_key="snapshots_sub", - supported=lambda api, ch: api.supported(ch, "snapshot"), + supported=lambda api, ch: ( + api.supported(ch, "snapshot") and api.supported(ch, "sub") + ), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="snapshots", stream="snapshots_main", translation_key="snapshots_main", - supported=lambda api, ch: api.supported(ch, "snapshot"), + supported=lambda api, ch: ( + api.supported(ch, "snapshot") and api.supported(ch, "main") + ), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( @@ -70,27 +78,35 @@ class ReolinkCameraEntityDescription( key="autotrack_sub", stream="telephoto_sub", translation_key="telephoto_sub", - supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + supported=lambda api, ch: ( + api.supported(ch, "autotrack_stream") and api.supported(ch, "sub") + ), ), ReolinkCameraEntityDescription( key="autotrack_main", stream="telephoto_main", translation_key="telephoto_main", - supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + supported=lambda api, ch: ( + api.supported(ch, "autotrack_stream") and api.supported(ch, "main") + ), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="autotrack_snapshots_sub", stream="autotrack_snapshots_sub", translation_key="telephoto_snapshots_sub", - supported=lambda api, ch: api.supported(ch, "autotrack_snapshot"), + supported=lambda api, ch: ( + api.supported(ch, "autotrack_snapshot") and api.supported(ch, "sub") + ), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="autotrack_snapshots_main", stream="autotrack_snapshots_main", translation_key="telephoto_snapshots_main", - supported=lambda api, ch: api.supported(ch, "autotrack_snapshot"), + supported=lambda api, ch: ( + api.supported(ch, "autotrack_snapshot") and api.supported(ch, "main") + ), entity_registry_enabled_default=False, ), ) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index c584a891137973..cf5461420d3542 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -253,7 +253,9 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_registry_enabled_default=False, unit_of_measurement=UnitOfFrequency.HERTZ, get_options=lambda api, ch: [str(v) for v in api.frame_rate_list(ch, "main")], - supported=lambda api, ch: api.supported(ch, "frame_rate"), + supported=lambda api, ch: ( + api.supported(ch, "frame_rate") and api.supported(ch, "main") + ), value=lambda api, ch: str(api.frame_rate(ch, "main")), method=lambda api, ch, value: api.set_frame_rate(ch, int(value), "main"), ), @@ -266,7 +268,9 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_registry_enabled_default=False, unit_of_measurement=UnitOfFrequency.HERTZ, get_options=lambda api, ch: [str(v) for v in api.frame_rate_list(ch, "sub")], - supported=lambda api, ch: api.supported(ch, "frame_rate"), + supported=lambda api, ch: ( + api.supported(ch, "frame_rate") and api.supported(ch, "sub") + ), value=lambda api, ch: str(api.frame_rate(ch, "sub")), method=lambda api, ch, value: api.set_frame_rate(ch, int(value), "sub"), ), @@ -279,7 +283,9 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_registry_enabled_default=False, unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, get_options=lambda api, ch: [str(v) for v in api.bit_rate_list(ch, "main")], - supported=lambda api, ch: api.supported(ch, "bit_rate"), + supported=lambda api, ch: ( + api.supported(ch, "bit_rate") and api.supported(ch, "main") + ), value=lambda api, ch: str(api.bit_rate(ch, "main")), method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "main"), ), @@ -292,7 +298,9 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_registry_enabled_default=False, unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, get_options=lambda api, ch: [str(v) for v in api.bit_rate_list(ch, "sub")], - supported=lambda api, ch: api.supported(ch, "bit_rate"), + supported=lambda api, ch: ( + api.supported(ch, "bit_rate") and api.supported(ch, "sub") + ), value=lambda api, ch: str(api.bit_rate(ch, "sub")), method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "sub"), ), @@ -304,7 +312,9 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, get_options=[val.name for val in EncodingEnum], - supported=lambda api, ch: api.supported(ch, "encoding"), + supported=lambda api, ch: ( + api.supported(ch, "encoding") and api.supported(ch, "main") + ), value=lambda api, ch: api.encoding(ch, "main"), method=lambda api, ch, value: api.set_encoding(ch, value, "main"), ), @@ -316,7 +326,9 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, get_options=[val.name for val in EncodingEnum], - supported=lambda api, ch: api.supported(ch, "encoding"), + supported=lambda api, ch: ( + api.supported(ch, "encoding") and api.supported(ch, "sub") + ), value=lambda api, ch: api.encoding(ch, "sub"), method=lambda api, ch, value: api.set_encoding(ch, value, "sub"), ), diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index a2a0bbd49e1ec0..bb035855d0f831 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -6,7 +6,8 @@ Configuration of options through options flow. """ -from collections.abc import Mapping +from collections.abc import Iterator, Mapping +from contextlib import contextmanager import operator import socket from types import MappingProxyType @@ -17,7 +18,7 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntryState, + ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -58,6 +59,7 @@ from .errors import AuthenticationRequired, CannotConnect from .hub import UnifiHub, get_unifi_api +DEFAULT_HOST = "unifi" DEFAULT_PORT = 443 DEFAULT_SITE_ID = "default" DEFAULT_VERIFY_SSL = False @@ -89,63 +91,34 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - self.config = { - CONF_HOST: user_input[CONF_HOST], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_PORT: user_input.get(CONF_PORT), - CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL), - CONF_SITE_ID: DEFAULT_SITE_ID, - } - - try: - hub = await get_unifi_api(self.hass, MappingProxyType(self.config)) - await hub.sites.update() - self.sites = hub.sites - - except AuthenticationRequired: - errors["base"] = "faulty_credentials" - - except CannotConnect: - errors["base"] = "service_unavailable" - - else: - if self.source == SOURCE_REAUTH: - if ( - (reauth_unique_id := self._get_reauth_entry().unique_id) - is not None - ) and reauth_unique_id in self.sites: - return await self.async_step_site( - {CONF_SITE_ID: reauth_unique_id} - ) - raise AbortFlow("unknown_site_id") + self.config = _config_from_input(user_input) + data_schema = self._build_form_schema( + self.config[CONF_HOST], + self.config[CONF_USERNAME], + self.config[CONF_PORT], + self.config[CONF_VERIFY_SSL], + ) + with _catch_unifi_api_flow_errors(errors): + self.sites = await self._async_update_sites(self.config) return await self.async_step_site() - - if not (host := self.config.get(CONF_HOST, "")) and await _async_discover_unifi( - self.hass - ): - host = "unifi" - - data = self.reauth_schema or { - probatio.Required(CONF_HOST, default=host): str, - probatio.Required(CONF_USERNAME): str, - probatio.Required(CONF_PASSWORD): str, - probatio.Optional( - CONF_PORT, default=self.config.get(CONF_PORT, DEFAULT_PORT) - ): int, - probatio.Optional( - CONF_VERIFY_SSL, - default=self.config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), - ): bool, - } + else: + host = self.config.get(CONF_HOST) + if not host: + host = await _async_discover_unifi(self.hass) + if not host: + host = DEFAULT_HOST + data_schema = self._build_form_schema( + host=host, + verify_ssl=self.config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ) return self.async_show_form( step_id="user", - data_schema=probatio.Schema(data), + data_schema=data_schema, errors=errors, ) @@ -157,24 +130,8 @@ async def async_step_site( unique_id = user_input[CONF_SITE_ID] self.config[CONF_SITE_ID] = self.sites[unique_id].name - config_entry = await self.async_set_unique_id(unique_id) - abort_reason = "configuration_updated" - - if self.source == SOURCE_REAUTH: - config_entry = self._get_reauth_entry() - abort_reason = "reauth_successful" - - if config_entry: - if ( - config_entry.state is ConfigEntryState.LOADED - and (hub := config_entry.runtime_data) - and hub.available - ): - return self.async_abort(reason="already_configured") - - return self.async_update_and_abort( - config_entry, data=self.config, reason=abort_reason - ) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() site_nice_name = self.sites[unique_id].description return self.async_create_entry(title=site_nice_name, data=self.config) @@ -195,25 +152,53 @@ async def async_step_reauth( ) -> ConfigFlowResult: """Trigger a reauthentication flow.""" reauth_entry = self._get_reauth_entry() - self.context["title_placeholders"] = { CONF_HOST: reauth_entry.data[CONF_HOST], CONF_NAME: reauth_entry.title, } - self.reauth_schema = { - probatio.Required(CONF_HOST, default=reauth_entry.data[CONF_HOST]): str, - probatio.Required( - CONF_USERNAME, default=reauth_entry.data[CONF_USERNAME] - ): str, - probatio.Required(CONF_PASSWORD): str, - probatio.Required(CONF_PORT, default=reauth_entry.data[CONF_PORT]): int, - probatio.Required( - CONF_VERIFY_SSL, default=reauth_entry.data[CONF_VERIFY_SSL] - ): bool, - } + return await self.async_step_reconfigure() - return await self.async_step_user() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow.""" + config_entry = self._get_reauth_or_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + config_data = _config_from_input(user_input) + data_schema = self._build_form_schema( + config_data[CONF_HOST], + config_data[CONF_USERNAME], + config_data[CONF_PORT], + config_data[CONF_VERIFY_SSL], + ) + + with _catch_unifi_api_flow_errors(errors): + sites = await self._async_update_sites(config_data) + + if ( + (unique_id := config_entry.unique_id) is not None + ) and unique_id in sites: + config_data[CONF_SITE_ID] = sites[unique_id].name + return self.async_update_reload_and_abort( + config_entry, data_updates=config_data + ) + raise AbortFlow("unknown_site_id") + else: + data_schema = self._build_form_schema( + config_entry.data[CONF_HOST], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PORT], + config_entry.data[CONF_VERIFY_SSL], + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=data_schema, + errors=errors, + ) @override async def async_step_integration_discovery( @@ -259,6 +244,39 @@ async def async_step_integration_discovery( return await self.async_step_user() + def _build_form_schema( + self, + host: str = DEFAULT_HOST, + username: str = "", + port: int = DEFAULT_PORT, + verify_ssl: bool = DEFAULT_VERIFY_SSL, + ) -> probatio.Schema: + return probatio.Schema( + { + probatio.Required(CONF_HOST, default=host): str, + probatio.Required(CONF_USERNAME, default=username): str, + probatio.Required(CONF_PASSWORD): str, + probatio.Optional(CONF_PORT, default=port): int, + probatio.Optional( + CONF_VERIFY_SSL, + default=verify_ssl, + ): bool, + } + ) + + async def _async_update_sites(self, data: Mapping[str, Any]) -> Sites: + """Get updated sites through UniFi API.""" + hub = await get_unifi_api(self.hass, MappingProxyType(data)) + await hub.sites.update() + return hub.sites + + @callback + def _get_reauth_or_reconfigure_entry(self) -> ConfigEntry: + """Return the config entry the current flow is modifying.""" + if self.source == SOURCE_REAUTH: + return self._get_reauth_entry() + return self._get_reconfigure_entry() + class UnifiOptionsFlowHandler(OptionsFlow): """Handle Unifi Network options.""" @@ -407,3 +425,26 @@ async def _async_discover_unifi(hass: HomeAssistant) -> str | None: return await hass.async_add_executor_job(socket.gethostbyname, "unifi") except socket.gaierror: return None + + +def _config_from_input(user_input: dict[str, Any]) -> dict[str, Any]: + """Build config entry data from user input.""" + return { + CONF_HOST: user_input[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_PORT: user_input.get(CONF_PORT), + CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL), + CONF_SITE_ID: DEFAULT_SITE_ID, + } + + +@contextmanager +def _catch_unifi_api_flow_errors(errors: dict[str, str]) -> Iterator[None]: + """Map UniFi API exceptions to config flow form errors.""" + try: + yield + except AuthenticationRequired: + errors["base"] = "faulty_credentials" + except CannotConnect: + errors["base"] = "service_unavailable" diff --git a/homeassistant/components/unifi/quality_scale.yaml b/homeassistant/components/unifi/quality_scale.yaml index 7d8fbc6a298625..8997987877720f 100644 --- a/homeassistant/components/unifi/quality_scale.yaml +++ b/homeassistant/components/unifi/quality_scale.yaml @@ -58,12 +58,7 @@ rules: entity-translations: done exception-translations: todo icon-translations: done - reconfiguration-flow: - status: todo - comment: | - The user flow currently allows updating existing config entry data - (host/credentials), which should be handled by a dedicated - async_step_reconfigure instead. + reconfiguration-flow: done repair-issues: todo stale-devices: status: todo diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 61698aab9c07a1..ebbc75d7432e73 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -9,7 +9,7 @@ from datetime import date, datetime, timedelta from decimal import Decimal from functools import partial -from typing import TYPE_CHECKING, Literal, override +from typing import TYPE_CHECKING, Literal, cast, override from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from aiounifi.interfaces.clients import Clients @@ -39,6 +39,8 @@ PERCENTAGE, EntityCategory, UnitOfDataRate, + UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfPower, UnitOfTime, ) @@ -200,6 +202,22 @@ def async_device_outlet_supported_fn(hub: UnifiHub, obj_id: str) -> bool: return hub.api.devices[obj_id].outlet_ac_power_budget is not None +@callback +def async_device_battery_pool_supported_fn( + field: str, hub: UnifiHub, obj_id: str +) -> bool: + """Determine if a device provides a battery pool field.""" + return field in (hub.api.devices[obj_id].battery_pool or {}) + + +@callback +def async_device_battery_pool_value_fn( + field: str, hub: UnifiHub, device: Device +) -> float | int: + """Retrieve a battery pool field.""" + return cast(dict[str, float | int], device.battery_pool)[field] + + @callback def async_device_uplink_mac_supported_fn(hub: UnifiHub, obj_id: str) -> bool: """Determine if a device supports reading uplink MAC address.""" @@ -404,7 +422,7 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ): """Class describing UniFi sensor entity.""" - value_fn: Callable[[UnifiHub, ApiItemT], datetime | float | str | None] + value_fn: Callable[[UnifiHub, ApiItemT], datetime | float | int | str | None] # Optional is_connected_fn: Callable[[UnifiHub, str], bool] | None = None @@ -625,6 +643,139 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( unique_id_fn=lambda hub, obj_id: f"ac_power_conumption-{obj_id}", value_fn=lambda hub, device: device.outlet_ac_power_consumption, ), + UnifiSensorEntityDescription[Devices, Device]( + key="UPS battery level", + translation_key="ups_battery_level", + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=partial(async_device_battery_pool_supported_fn, "batteryLevel"), + unique_id_fn=lambda hub, obj_id: f"ups_battery_level-{obj_id}", + value_fn=partial(async_device_battery_pool_value_fn, "batteryLevel"), + ), + UnifiSensorEntityDescription[Devices, Device]( + key="UPS battery runtime", + translation_key="ups_battery_runtime", + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.SECONDS, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=partial(async_device_battery_pool_supported_fn, "timeToRemain"), + unique_id_fn=lambda hub, obj_id: f"ups_battery_runtime-{obj_id}", + value_fn=partial(async_device_battery_pool_value_fn, "timeToRemain"), + ), + UnifiSensorEntityDescription[Devices, Device]( + key="UPS output power", + translation_key="ups_output_power", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=partial( + async_device_battery_pool_supported_fn, "device_total_power_output" + ), + unique_id_fn=lambda hub, obj_id: f"ups_output_power-{obj_id}", + value_fn=partial( + async_device_battery_pool_value_fn, "device_total_power_output" + ), + ), + UnifiSensorEntityDescription[Devices, Device]( + key="UPS output current", + translation_key="ups_output_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=partial( + async_device_battery_pool_supported_fn, "device_output_current" + ), + unique_id_fn=lambda hub, obj_id: f"ups_output_current-{obj_id}", + value_fn=partial(async_device_battery_pool_value_fn, "device_output_current"), + ), + UnifiSensorEntityDescription[Devices, Device]( + key="UPS output voltage", + translation_key="ups_output_voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=partial( + async_device_battery_pool_supported_fn, "device_output_voltage" + ), + unique_id_fn=lambda hub, obj_id: f"ups_output_voltage-{obj_id}", + value_fn=partial(async_device_battery_pool_value_fn, "device_output_voltage"), + ), + UnifiSensorEntityDescription[Devices, Device]( + key="UPS input voltage", + translation_key="ups_input_voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=partial( + async_device_battery_pool_supported_fn, "device_input_voltage" + ), + unique_id_fn=lambda hub, obj_id: f"ups_input_voltage-{obj_id}", + value_fn=partial(async_device_battery_pool_value_fn, "device_input_voltage"), + ), + UnifiSensorEntityDescription[Devices, Device]( + key="UPS bypass voltage", + translation_key="ups_bypass_voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=partial( + async_device_battery_pool_supported_fn, "device_bypass_voltage" + ), + unique_id_fn=lambda hub, obj_id: f"ups_bypass_voltage-{obj_id}", + value_fn=partial(async_device_battery_pool_value_fn, "device_bypass_voltage"), + ), + UnifiSensorEntityDescription[Devices, Device]( + key="UPS output power factor", + translation_key="ups_output_power_factor", + device_class=SensorDeviceClass.POWER_FACTOR, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=partial( + async_device_battery_pool_supported_fn, "device_total_power_factor" + ), + unique_id_fn=lambda hub, obj_id: f"ups_output_power_factor-{obj_id}", + value_fn=partial( + async_device_battery_pool_value_fn, "device_total_power_factor" + ), + ), UnifiSensorEntityDescription[Devices, Device]( key="Device uptime", device_class=SensorDeviceClass.UPTIME, diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index c44b2d9c5260a7..84b8a6dfe2d42c 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -5,6 +5,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "configuration_updated": "Configuration updated", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown_site_id": "Previously configured UniFi Network site can no longer be found" }, "error": { @@ -14,6 +15,24 @@ }, "flow_title": "{name} ({host})", "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "site": "[%key:component::unifi::config::step::user::data::site%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "[%key:component::unifi::config::step::user::data_description::host%]", + "password": "[%key:component::unifi::config::step::user::data_description::password%]", + "port": "[%key:component::unifi::config::step::user::data_description::port%]", + "username": "[%key:component::unifi::config::step::user::data_description::username%]", + "verify_ssl": "[%key:component::unifi::config::step::user::data_description::verify_ssl%]" + }, + "title": "[%key:component::unifi::config::step::user::title%]" + }, "site": { "data": { "site": "Site ID" @@ -121,6 +140,30 @@ "smartpower_ac_power_consumption": { "name": "AC power consumption" }, + "ups_battery_level": { + "name": "Battery level" + }, + "ups_battery_runtime": { + "name": "Battery runtime" + }, + "ups_bypass_voltage": { + "name": "Bypass voltage" + }, + "ups_input_voltage": { + "name": "Input voltage" + }, + "ups_output_current": { + "name": "Output current" + }, + "ups_output_power": { + "name": "Output power" + }, + "ups_output_power_factor": { + "name": "Output power factor" + }, + "ups_output_voltage": { + "name": "Output voltage" + }, "wan_latency": { "name": "{target} {wan} latency" }, diff --git a/homeassistant/components/volkszaehler/manifest.json b/homeassistant/components/volkszaehler/manifest.json index 56c5d7a27733aa..3f9f24b76c434f 100644 --- a/homeassistant/components/volkszaehler/manifest.json +++ b/homeassistant/components/volkszaehler/manifest.json @@ -1,7 +1,7 @@ { "domain": "volkszaehler", "name": "Volkszaehler", - "codeowners": [], + "codeowners": ["@StefanSchoof"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/volkszaehler", "integration_type": "device", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e499cd6194f05d..691ca079b1a219 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -72,7 +72,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.12 +uv==0.12.13 webrtc-models==0.3.0 yarl==1.25.1 zeroconf==0.151.3 diff --git a/pyproject.toml b/pyproject.toml index ba8d9bd5b63042..58bd0f6e2739ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dependencies = [ "typing-extensions>=4.16.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.12.12", + "uv==0.12.13", "probatio==0.12.1", "yarl==1.25.1", "webrtc-models==0.3.0", @@ -638,7 +638,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.16.6" +required-version = ">=0.16.7" [tool.ruff.lint] select = [ diff --git a/requirements.txt b/requirements.txt index e27870c0bcaf26..89b6f4c9710a24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.12 +uv==0.12.13 webrtc-models==0.3.0 yarl==1.25.1 zeroconf==0.151.3 diff --git a/requirements_all.txt b/requirements_all.txt index 155990c3e65cec..fcfad60f3b858d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -809,7 +809,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.cookidoo -cookidoo-api==0.17.2 +cookidoo-api==0.18.4 # homeassistant.components.backup # homeassistant.components.utility_meter @@ -1647,7 +1647,7 @@ millheater==0.14.1 minio==7.1.12 # homeassistant.components.mitsubishi_comfort -mitsubishi-comfort==0.5.2 +mitsubishi-comfort==0.6.0 # homeassistant.components.moat moat-ble==0.1.1 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 89ce864961d6d4..0372cda28d8eee 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.3 -ruff==0.16.6 +ruff==0.16.7 yamllint==1.38.0 zizmor==1.30.1 diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 952b31ab6ebd3e..a2493ce7c638a1 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -1,10 +1,13 @@ """Common fixtures for the Cookidoo tests.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from collections.abc import Callable, Generator +from dataclasses import asdict +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from cookidoo_api import ( CookidooAdditionalItem, + CookidooAuthData, CookidooIngredientItem, CookidooSubscription, CookidooUserInfo, @@ -13,7 +16,13 @@ import pytest from homeassistant.components.cookidoo.const import DOMAIN -from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + CONF_TOKEN, +) from tests.common import MockConfigEntry, load_json_object_fixture @@ -24,6 +33,17 @@ TEST_UUID = "sub_uuid" +AUTH_DATA = CookidooAuthData( + access_token="test-access-token", + refresh_token="test-refresh-token", + expires_at=1762000000.0, +) +STALE_AUTH_DATA = CookidooAuthData( + access_token="stale-access-token", + refresh_token="stale-refresh-token", + expires_at=1761000000.0, +) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -34,52 +54,122 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -@pytest.fixture -def mock_cookidoo_client() -> Generator[AsyncMock]: - """Mock a Cookidoo client.""" +@pytest.fixture(name="mock_cookidoo") +def mock_cookidoo_class() -> Generator[MagicMock]: + """Mock the Cookidoo class the integration instantiates.""" with patch( "homeassistant.components.cookidoo.helpers.Cookidoo", autospec=True, ) as mock_client: - client = mock_client.return_value - client.login.return_value = None - client.get_ingredient_items.return_value = [ - CookidooIngredientItem(**item) - for item in load_json_object_fixture("ingredient_items.json", DOMAIN)[ - "data" - ] - ] - client.get_additional_items.return_value = [ - CookidooAdditionalItem(**item) - for item in load_json_object_fixture("additional_items.json", DOMAIN)[ - "data" - ] - ] - client.get_active_subscription.return_value = CookidooSubscription( - **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] + yield mock_client + + +@pytest.fixture +def notify_auth_data_update( + mock_cookidoo: MagicMock, +) -> Callable[[CookidooAuthData | None], None]: + """Emulate the library notifying its consumer of new tokens. + + A token response without a refresh token leaves the library with nothing to + hand over, which is what passing None stands for. + """ + + def _notify(auth_data: CookidooAuthData | None) -> None: + if auth_data is None: + return + mock_cookidoo.return_value.auth_data = auth_data + mock_cookidoo.call_args.kwargs["on_auth_data_update"](auth_data) + + return _notify + + +@pytest.fixture +def login_success( + notify_auth_data_update: Callable[[CookidooAuthData | None], None], +) -> Callable[[], None]: + """Emulate a successful login: fresh tokens, handed to the consumer.""" + + def _login() -> None: + notify_auth_data_update(AUTH_DATA) + + return _login + + +@pytest.fixture +def mock_cookidoo_client( + mock_cookidoo: MagicMock, + login_success: Callable[[], None], +) -> AsyncMock: + """Mock a Cookidoo client.""" + client = mock_cookidoo.return_value + # No tokens until a login provides them or the consumer restores them + client.auth_data = None + client.login.side_effect = login_success + client.apply_auth_data.side_effect = lambda auth_data: setattr( + client, "auth_data", auth_data + ) + client.get_ingredient_items.return_value = [ + CookidooIngredientItem(**item) + for item in load_json_object_fixture("ingredient_items.json", DOMAIN)["data"] + ] + client.get_additional_items.return_value = [ + CookidooAdditionalItem(**item) + for item in load_json_object_fixture("additional_items.json", DOMAIN)["data"] + ] + client.get_active_subscription.return_value = CookidooSubscription( + **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] + ) + client.get_user_info.return_value = CookidooUserInfo( + **load_json_object_fixture("user_info.json", DOMAIN)["data"] + ) + client.get_recipes_in_calendar_week.return_value = [ + CookidooCalendarDay( + id=day["id"], + title=day["title"], + recipes=[ + CookidooCalendarDayRecipe( + id=recipe["id"], + name=recipe["name"], + total_time=recipe["total_time"], + thumbnail=recipe["thumbnail"], + image=recipe["image"], + url=recipe["url"], + ) + for recipe in day["recipes"] + ], ) - client.get_user_info.return_value = CookidooUserInfo( - **load_json_object_fixture("user_info.json", DOMAIN)["data"] + for day in load_json_object_fixture("calendar_week.json", DOMAIN)["data"] + ] + return client + + +@pytest.fixture +def arrange_validation_tokens( + mock_cookidoo_client: AsyncMock, + notify_auth_data_update: Callable[[CookidooAuthData | None], None], +) -> Callable[[CookidooAuthData | None, CookidooAuthData | None], None]: + """Arrange the tokens the config flow validation requests hand over. + + The login hands over the first, and the additional items fetch that follows + it the second, which is how a request rotating the tokens mid-validation + presents itself. Either can be None, for a response without a token. + """ + + def _arrange( + login_tokens: CookidooAuthData | None, + rotated_tokens: CookidooAuthData | None, + ) -> None: + mock_cookidoo_client.login.side_effect = lambda: notify_auth_data_update( + login_tokens ) - client.get_recipes_in_calendar_week.return_value = [ - CookidooCalendarDay( - id=day["id"], - title=day["title"], - recipes=[ - CookidooCalendarDayRecipe( - id=recipe["id"], - name=recipe["name"], - total_time=recipe["total_time"], - thumbnail=recipe["thumbnail"], - image=recipe["image"], - url=recipe["url"], - ) - for recipe in day["recipes"] - ], - ) - for day in load_json_object_fixture("calendar_week.json", DOMAIN)["data"] - ] - yield client + + async def _get_additional_items(*args: Any, **kwargs: Any) -> list: + notify_auth_data_update(rotated_tokens) + return [] + + mock_cookidoo_client.get_additional_items.side_effect = _get_additional_items + + return _arrange @pytest.fixture(name="cookidoo_config_entry") @@ -98,3 +188,22 @@ def mock_cookidoo_config_entry() -> MockConfigEntry: entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", unique_id=TEST_UUID, ) + + +@pytest.fixture(name="cookidoo_config_entry_with_token") +def mock_cookidoo_config_entry_with_token() -> MockConfigEntry: + """Mock a cookidoo configuration entry holding persisted OAuth2 tokens.""" + return MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=3, + data={ + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRY: COUNTRY, + CONF_LANGUAGE: LANGUAGE, + CONF_TOKEN: asdict(STALE_AUTH_DATA), + }, + entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + unique_id=TEST_UUID, + ) diff --git a/tests/components/cookidoo/snapshots/test_diagnostics.ambr b/tests/components/cookidoo/snapshots/test_diagnostics.ambr index fd2bfe3e082f40..74b9caa013379c 100644 --- a/tests/components/cookidoo/snapshots/test_diagnostics.ambr +++ b/tests/components/cookidoo/snapshots/test_diagnostics.ambr @@ -67,6 +67,7 @@ 'email': 'test-email', 'language': 'de-CH', 'password': '**REDACTED**', + 'token': '**REDACTED**', }), 'user': dict({ 'description': None, diff --git a/tests/components/cookidoo/test_calendar.py b/tests/components/cookidoo/test_calendar.py index 89274e662fc7a7..5ca77492799598 100644 --- a/tests/components/cookidoo/test_calendar.py +++ b/tests/components/cookidoo/test_calendar.py @@ -1,20 +1,27 @@ """Test for calendar platform of the Cookidoo integration.""" -from collections.abc import Generator -from datetime import UTC, datetime +from collections.abc import Callable, Generator +from dataclasses import asdict +from datetime import UTC, date, datetime from unittest.mock import AsyncMock, patch -from cookidoo_api import CookidooAuthException, CookidooRequestException +from cookidoo_api import ( + CookidooAuthData, + CookidooAuthException, + CookidooParseException, + CookidooRequestException, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration +from .conftest import AUTH_DATA from tests.common import MockConfigEntry, snapshot_platform @@ -88,8 +95,10 @@ async def test_get_events( @pytest.mark.parametrize( "login_exception", [ - CookidooAuthException(), - CookidooRequestException(), + pytest.param(CookidooAuthException(), id="auth"), + pytest.param(CookidooRequestException(), id="request"), + pytest.param(CookidooParseException(), id="parse"), + pytest.param(None, id="retry_fails_after_successful_login"), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -98,9 +107,13 @@ async def test_get_events_login_failure( cookidoo_config_entry: MockConfigEntry, mock_cookidoo_client: AsyncMock, entity_registry: er.EntityRegistry, - login_exception: Exception, + login_exception: Exception | None, ) -> None: - """Test calendar handles login failures gracefully during event fetch.""" + """Test calendar handles login failures gracefully during event fetch. + + With no login exception the login succeeds and the retried fetch fails + instead, which must be reported the same way. + """ with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.CALENDAR]): await setup_integration(hass, cookidoo_config_entry) @@ -131,3 +144,81 @@ async def test_get_events_login_failure( blocking=True, return_response=True, ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_get_events_relogin_persists_tokens( + hass: HomeAssistant, + cookidoo_config_entry_with_token: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test tokens of a calendar re-login are persisted on the config entry.""" + await setup_integration(hass, cookidoo_config_entry_with_token) + + entities = er.async_entries_for_config_entry( + entity_registry, cookidoo_config_entry_with_token.entry_id + ) + entity_id = entities[0].entity_id + + week_plan = mock_cookidoo_client.get_recipes_in_calendar_week.return_value + mock_cookidoo_client.get_recipes_in_calendar_week.side_effect = [ + CookidooAuthException(), + week_plan, + week_plan, + ] + + await hass.services.async_call( + "calendar", + "get_events", + { + "start_date_time": datetime(2025, 3, 4, tzinfo=UTC), + "end_date_time": datetime(2025, 3, 6, tzinfo=UTC), + }, + target={"entity_id": entity_id}, + blocking=True, + return_response=True, + ) + + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_get_events_persists_rotated_tokens( + hass: HomeAssistant, + cookidoo_config_entry_with_token: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + notify_auth_data_update: Callable[[CookidooAuthData], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test tokens rotated during a plain calendar fetch are persisted.""" + await setup_integration(hass, cookidoo_config_entry_with_token) + + entities = er.async_entries_for_config_entry( + entity_registry, cookidoo_config_entry_with_token.entry_id + ) + entity_id = entities[0].entity_id + + # The library rotates the tokens while serving the fetch, without a login + week_plan = mock_cookidoo_client.get_recipes_in_calendar_week.return_value + + def _rotate(week_day: date) -> list: + notify_auth_data_update(AUTH_DATA) + return week_plan + + mock_cookidoo_client.get_recipes_in_calendar_week.side_effect = _rotate + + await hass.services.async_call( + "calendar", + "get_events", + { + "start_date_time": datetime(2025, 3, 4, tzinfo=UTC), + "end_date_time": datetime(2025, 3, 6, tzinfo=UTC), + }, + target={"entity_id": entity_id}, + blocking=True, + return_response=True, + ) + + mock_cookidoo_client.login.assert_not_awaited() + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA) diff --git a/tests/components/cookidoo/test_config_flow.py b/tests/components/cookidoo/test_config_flow.py index 7e134422441332..eedf78be141c61 100644 --- a/tests/components/cookidoo/test_config_flow.py +++ b/tests/components/cookidoo/test_config_flow.py @@ -1,21 +1,32 @@ """Test the Cookidoo config flow.""" +from collections.abc import Callable +from dataclasses import asdict +from typing import Any from unittest.mock import AsyncMock +from cookidoo_api import CookidooAuthData from cookidoo_api.exceptions import ( CookidooAuthException, CookidooException, + CookidooParseException, CookidooRequestException, ) import pytest from homeassistant.components.cookidoo.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD +from homeassistant.const import ( + CONF_COUNTRY, + CONF_EMAIL, + CONF_LANGUAGE, + CONF_PASSWORD, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD +from .conftest import AUTH_DATA, COUNTRY, EMAIL, LANGUAGE, PASSWORD from .test_init import setup_integration from tests.common import MockConfigEntry @@ -30,11 +41,45 @@ CONF_LANGUAGE: LANGUAGE, } +MOCK_TOKEN = asdict(AUTH_DATA) +ROTATED_AUTH_DATA = CookidooAuthData( + access_token="rotated-access-token", + refresh_token="rotated-refresh-token", + expires_at=1763000000.0, +) + +@pytest.mark.parametrize( + ("login_tokens", "rotated_tokens", "expected_token"), + [ + pytest.param(AUTH_DATA, None, MOCK_TOKEN, id="tokens_from_the_login"), + pytest.param( + AUTH_DATA, + ROTATED_AUTH_DATA, + asdict(ROTATED_AUTH_DATA), + id="tokens_rotated_during_validation", + ), + pytest.param(None, None, {}, id="no_tokens_from_the_login"), + ], +) async def test_flow_user_success( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_cookidoo_client: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + arrange_validation_tokens: Callable[ + [CookidooAuthData | None, CookidooAuthData | None], None + ], + login_tokens: CookidooAuthData | None, + rotated_tokens: CookidooAuthData | None, + expected_token: dict[str, Any], ) -> None: - """Test we get the user flow and create entry with success.""" + """Test we get the user flow and create entry with success. + + The entry is created with whatever tokens the validation ended up holding: + the ones the login handed over, the ones a later request rotated them into, + or none at all. + """ + arrange_validation_tokens(login_tokens, rotated_tokens) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -57,14 +102,57 @@ async def test_flow_user_success( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Cookidoo" - assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + assert result["data"] == { + **MOCK_DATA_USER_STEP, + **MOCK_DATA_LANGUAGE_STEP, + CONF_TOKEN: expected_token, + } assert len(mock_setup_entry.mock_calls) == 1 +async def test_flow_reauth_drops_tokens_of_a_failed_attempt( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test a retried reauth does not persist the tokens of an earlier attempt. + + The first attempt logs in -- which hands us its tokens -- and only then + fails, and the retry logs in without any. Those tokens belong to the + credentials that were rejected, so they must not reach the entry. + """ + await setup_integration(hass, cookidoo_config_entry) + mock_cookidoo_client.reset_mock() + + result = await cookidoo_config_entry.start_reauth_flow(hass) + + user_info = mock_cookidoo_client.get_user_info.return_value + mock_cookidoo_client.get_user_info.side_effect = [ + CookidooRequestException(), + user_info, + ] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "wrong-email", CONF_PASSWORD: "wrong-password"}, + ) + assert result["errors"] == {"base": "cannot_connect"} + + # The retried login yields no tokens, so nothing overwrites the stale pair + mock_cookidoo_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + assert result["reason"] == "reauth_successful" + assert cookidoo_config_entry.data[CONF_TOKEN] == {} + + @pytest.mark.parametrize( ("raise_error", "text_error"), [ (CookidooRequestException(), "cannot_connect"), + (CookidooParseException(), "cannot_connect"), (CookidooAuthException(), "invalid_auth"), (CookidooException(), "unknown"), (IndexError(), "unknown"), @@ -73,6 +161,7 @@ async def test_flow_user_success( async def test_flow_user_init_data_unknown_error_and_recover_on_step_1( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, + login_success: Callable[[], None], raise_error: Exception, text_error: str, ) -> None: @@ -91,7 +180,7 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_1( assert result["errors"]["base"] == text_error # Recover - mock_cookidoo_client.login.side_effect = None + mock_cookidoo_client.login.side_effect = login_success result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_DATA_USER_STEP, @@ -108,13 +197,18 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_1( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].title == "Cookidoo" - assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + assert result["data"] == { + **MOCK_DATA_USER_STEP, + **MOCK_DATA_LANGUAGE_STEP, + CONF_TOKEN: MOCK_TOKEN, + } @pytest.mark.parametrize( ("raise_error", "text_error"), [ (CookidooRequestException(), "cannot_connect"), + (CookidooParseException(), "cannot_connect"), (CookidooAuthException(), "invalid_auth"), (CookidooException(), "unknown"), (IndexError(), "unknown"), @@ -158,7 +252,11 @@ async def test_flow_user_init_data_unknown_error_and_recover_on_step_2( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].title == "Cookidoo" - assert result["data"] == {**MOCK_DATA_USER_STEP, **MOCK_DATA_LANGUAGE_STEP} + assert result["data"] == { + **MOCK_DATA_USER_STEP, + **MOCK_DATA_LANGUAGE_STEP, + CONF_TOKEN: MOCK_TOKEN, + } async def test_flow_user_init_data_already_configured( @@ -224,6 +322,7 @@ async def test_flow_reconfigure_success( CONF_PASSWORD: "new-password", CONF_COUNTRY: "DE", CONF_LANGUAGE: "de-DE", + CONF_TOKEN: MOCK_TOKEN, } assert len(hass.config_entries.async_entries()) == 1 @@ -232,6 +331,7 @@ async def test_flow_reconfigure_success( ("raise_error", "text_error"), [ (CookidooRequestException(), "cannot_connect"), + (CookidooParseException(), "cannot_connect"), (CookidooException(), "unknown"), (IndexError(), "unknown"), ], @@ -240,6 +340,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1( hass: HomeAssistant, cookidoo_config_entry: AsyncMock, mock_cookidoo_client: AsyncMock, + login_success: Callable[[], None], raise_error: Exception, text_error: str, ) -> None: @@ -263,7 +364,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1( assert result["errors"]["base"] == text_error # Recover - mock_cookidoo_client.login.side_effect = None + mock_cookidoo_client.login.side_effect = login_success result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={**MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE"}, @@ -283,6 +384,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1( **MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE", CONF_LANGUAGE: "de-DE", + CONF_TOKEN: MOCK_TOKEN, } assert len(hass.config_entries.async_entries()) == 1 @@ -291,6 +393,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_1( ("raise_error", "text_error"), [ (CookidooRequestException(), "cannot_connect"), + (CookidooParseException(), "cannot_connect"), (CookidooException(), "unknown"), (IndexError(), "unknown"), ], @@ -343,6 +446,7 @@ async def test_flow_reconfigure_init_data_unknown_error_and_recover_on_step_2( **MOCK_DATA_USER_STEP, CONF_COUNTRY: "DE", CONF_LANGUAGE: "de-DE", + CONF_TOKEN: MOCK_TOKEN, } assert len(hass.config_entries.async_entries()) == 1 @@ -401,6 +505,7 @@ async def test_flow_reauth( CONF_PASSWORD: "new-password", CONF_COUNTRY: COUNTRY, CONF_LANGUAGE: LANGUAGE, + CONF_TOKEN: MOCK_TOKEN, } assert len(hass.config_entries.async_entries()) == 1 @@ -409,6 +514,7 @@ async def test_flow_reauth( ("raise_error", "text_error"), [ (CookidooRequestException(), "cannot_connect"), + (CookidooParseException(), "cannot_connect"), (CookidooAuthException(), "invalid_auth"), (CookidooException(), "unknown"), (IndexError(), "unknown"), @@ -418,6 +524,7 @@ async def test_flow_reauth_error_and_recover( hass: HomeAssistant, mock_cookidoo_client: AsyncMock, cookidoo_config_entry: MockConfigEntry, + login_success: Callable[[], None], raise_error, text_error, ) -> None: @@ -438,7 +545,7 @@ async def test_flow_reauth_error_and_recover( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": text_error} - mock_cookidoo_client.login.side_effect = None + mock_cookidoo_client.login.side_effect = login_success result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, @@ -451,6 +558,7 @@ async def test_flow_reauth_error_and_recover( CONF_PASSWORD: "new-password", CONF_COUNTRY: COUNTRY, CONF_LANGUAGE: LANGUAGE, + CONF_TOKEN: MOCK_TOKEN, } assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/cookidoo/test_init.py b/tests/components/cookidoo/test_init.py index 8de8932c2f2e73..afca9819e84d5a 100644 --- a/tests/components/cookidoo/test_init.py +++ b/tests/components/cookidoo/test_init.py @@ -1,8 +1,17 @@ """Unit tests for the cookidoo integration.""" +from collections.abc import Callable +from dataclasses import asdict +from datetime import timedelta from unittest.mock import AsyncMock -from cookidoo_api import CookidooAuthException, CookidooRequestException +from cookidoo_api import ( + CookidooAuthData, + CookidooAuthException, + CookidooParseException, + CookidooRequestException, +) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.cookidoo.const import DOMAIN @@ -12,15 +21,24 @@ CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD, + CONF_TOKEN, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration -from .conftest import COUNTRY, EMAIL, LANGUAGE, PASSWORD, TEST_UUID +from .conftest import ( + AUTH_DATA, + COUNTRY, + EMAIL, + LANGUAGE, + PASSWORD, + STALE_AUTH_DATA, + TEST_UUID, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("mock_cookidoo_client") @@ -45,6 +63,7 @@ async def test_load_unload( [ (CookidooRequestException, ConfigEntryState.SETUP_RETRY), (CookidooAuthException, ConfigEntryState.SETUP_ERROR), + (CookidooParseException, ConfigEntryState.SETUP_RETRY), ], ) async def test_init_failure( @@ -85,11 +104,27 @@ async def test_config_entry_not_ready( @pytest.mark.parametrize( - ("login_exception", "status"), + ("login_exception", "status", "reason"), [ - (None, ConfigEntryState.LOADED), - (CookidooRequestException(), ConfigEntryState.SETUP_RETRY), - (CookidooAuthException(), ConfigEntryState.SETUP_ERROR), + pytest.param(None, ConfigEntryState.LOADED, None, id="relogin_succeeds"), + pytest.param( + CookidooRequestException(), + ConfigEntryState.SETUP_RETRY, + "Failed to connect to server, try again later", + id="request", + ), + pytest.param( + CookidooAuthException(), + ConfigEntryState.SETUP_ERROR, + "Authentication failed for test-email, check your email and password", + id="auth", + ), + pytest.param( + CookidooParseException(), + ConfigEntryState.SETUP_RETRY, + "Failed to connect to server, try again later", + id="parse", + ), ], ) async def test_config_entry_not_ready_auth_error( @@ -98,6 +133,7 @@ async def test_config_entry_not_ready_auth_error( mock_cookidoo_client: AsyncMock, login_exception: Exception | None, status: ConfigEntryState, + reason: str | None, ) -> None: """Test config entry recovery when data fetch hits an auth error. @@ -121,6 +157,8 @@ async def test_config_entry_not_ready_auth_error( await hass.async_block_till_done() assert cookidoo_config_entry.state is status + # A translated reason proves the exception was handled rather than escaping + assert cookidoo_config_entry.reason == reason MOCK_CONFIG_ENTRY_MIGRATION = { @@ -344,6 +382,20 @@ async def test_migration_from_partial_duplicate_unique_ids( "old_ciam_sub_uuid", CookidooAuthException, ), + ( + 1, + 1, + MOCK_CONFIG_ENTRY_MIGRATION, + None, + CookidooParseException, + ), + ( + 1, + 2, + MOCK_CONFIG_ENTRY_MIGRATION, + "old_ciam_sub_uuid", + CookidooParseException, + ), ], ) async def test_migration_from_with_error( @@ -356,6 +408,7 @@ async def test_migration_from_with_error( unique_id, login_exception: Exception, mock_cookidoo_client: AsyncMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test different expected migration paths but with connection issues.""" # Migration can fail due to connection issues as we have to fetch the uuid @@ -406,6 +459,8 @@ async def test_migration_from_with_error( await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + # A handled failure, rather than the exception escaping async_migrate_entry + assert "Could not migrate config entry" in caplog.text assert entity_registry.async_is_registered( entity_registry.entities.get_entity_id( @@ -434,3 +489,103 @@ async def test_migration_from_with_error( ) ) ) + + +async def test_login_persists_tokens( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test the OAuth2 tokens of a credential login are stored on the entry.""" + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + mock_cookidoo_client.login.assert_awaited_once() + assert cookidoo_config_entry.data[CONF_TOKEN] == asdict(AUTH_DATA) + + +async def test_tokens_persisted_when_user_info_fails( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, +) -> None: + """Test tokens of a successful login survive a failing user info fetch.""" + mock_cookidoo_client.get_user_info.side_effect = CookidooRequestException() + + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.SETUP_RETRY + # Without this the next attempt would replay the whole login + assert cookidoo_config_entry.data[CONF_TOKEN] == asdict(AUTH_DATA) + + +async def test_stored_tokens_skip_login( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry_with_token: MockConfigEntry, +) -> None: + """Test the persisted OAuth2 tokens are reused instead of logging in again.""" + await setup_integration(hass, cookidoo_config_entry_with_token) + + assert cookidoo_config_entry_with_token.state is ConfigEntryState.LOADED + mock_cookidoo_client.apply_auth_data.assert_called_once_with(STALE_AUTH_DATA) + mock_cookidoo_client.login.assert_not_awaited() + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(STALE_AUTH_DATA) + + +async def test_expired_tokens_fall_back_to_login( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry_with_token: MockConfigEntry, +) -> None: + """Test expired persisted tokens fall back to a credential login.""" + user_info = mock_cookidoo_client.get_user_info.return_value + mock_cookidoo_client.get_user_info.side_effect = [ + CookidooAuthException(), + user_info, + ] + + await setup_integration(hass, cookidoo_config_entry_with_token) + + assert cookidoo_config_entry_with_token.state is ConfigEntryState.LOADED + mock_cookidoo_client.login.assert_awaited_once() + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA) + + +@pytest.mark.parametrize( + "subscription_side_effect", + [ + pytest.param(None, id="update_succeeds"), + pytest.param(CookidooRequestException(), id="later_call_fails"), + ], +) +async def test_tokens_rotated_during_update_are_persisted( + hass: HomeAssistant, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry_with_token: MockConfigEntry, + notify_auth_data_update: Callable[[CookidooAuthData], None], + subscription_side_effect: Exception | None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test tokens the library rotates while serving an update are persisted. + + The refresh a request performs on its own rotates the refresh token with it, + so the new pair has to reach the entry whether the update as a whole went on + to succeed or a later call failed. + """ + await setup_integration(hass, cookidoo_config_entry_with_token) + + ingredient_items = mock_cookidoo_client.get_ingredient_items.return_value + + def _rotate() -> list: + notify_auth_data_update(AUTH_DATA) + return ingredient_items + + mock_cookidoo_client.get_ingredient_items.side_effect = _rotate + mock_cookidoo_client.get_active_subscription.side_effect = subscription_side_effect + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA) diff --git a/tests/components/cookidoo/test_todo.py b/tests/components/cookidoo/test_todo.py index d66c4f357c26a4..7fd3be0bc5bdd7 100644 --- a/tests/components/cookidoo/test_todo.py +++ b/tests/components/cookidoo/test_todo.py @@ -1,11 +1,13 @@ """Test for todo platform of the Cookidoo integration.""" -from collections.abc import Generator +from collections.abc import Callable, Generator +from dataclasses import asdict import re from unittest.mock import AsyncMock, patch from cookidoo_api import ( CookidooAdditionalItem, + CookidooAuthData, CookidooIngredientItem, CookidooRequestException, ) @@ -21,12 +23,13 @@ TodoServices, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration +from .conftest import AUTH_DATA from tests.common import MockConfigEntry, snapshot_platform @@ -291,3 +294,31 @@ async def test_delete_additional_items_exception( target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, blocking=True, ) + + +async def test_failed_action_persists_rotated_tokens( + hass: HomeAssistant, + cookidoo_config_entry_with_token: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + notify_auth_data_update: Callable[[CookidooAuthData], None], +) -> None: + """Test tokens rotated during a failing todo action are still persisted.""" + await setup_integration(hass, cookidoo_config_entry_with_token) + + # The library rotates the tokens while serving the request, which then fails + def _rotate_then_fail(uids: list[str]) -> None: + notify_auth_data_update(AUTH_DATA) + raise CookidooRequestException + + mock_cookidoo_client.remove_additional_items.side_effect = _rotate_then_fail + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.REMOVE_ITEM, + service_data={ATTR_ITEM: "unique_id_tomaten"}, + target={ATTR_ENTITY_ID: "todo.cookidoo_additional_purchases"}, + blocking=True, + ) + + assert cookidoo_config_entry_with_token.data[CONF_TOKEN] == asdict(AUTH_DATA) diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index ecc9e8a1fb4181..a80791684b015d 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -12,9 +12,12 @@ @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" - with patch( - "homeassistant.components.imap.async_setup_entry", return_value=True - ) as mock_setup_entry: + with ( + patch("homeassistant.components.imap.async_unload_entry", return_value=True), + patch( + "homeassistant.components.imap.async_setup_entry", return_value=True + ) as mock_setup_entry, + ): yield mock_setup_entry diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 3b96a19d059e5f..cc033042fb5c3a 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -347,6 +347,87 @@ async def test_options_form(hass: HomeAssistant) -> None: assert entry.data[key] == value +async def test_options_form_reset_template( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test resetting the custom_event_data_template option.""" + + # Set up an entry + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ( + "OK", + [b""], + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "email@email.com" + assert result2["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + entry = result2["result"] + + # Set the custom_event_data_template option via the options flow + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + new_config["custom_event_data_template"] = "{{ subject }}" + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + new_config, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == {} + for key, value in new_config.items(): + assert entry.data[key] == value + + assert "custom_event_data_template" in entry.data + + # Reset custom_event_data_template option + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + new_config, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == {} + for key, value in new_config.items(): + assert entry.data[key] == value + + assert "custom_event_data_template" not in entry.data + + async def test_key_options_in_options_form(hass: HomeAssistant) -> None: """Test we cannot change options if that would cause duplicates.""" diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py index 152fbdd2cf57d9..48c21e73bb0bdd 100644 --- a/tests/components/met/test_config_flow.py +++ b/tests/components/met/test_config_flow.py @@ -72,7 +72,11 @@ async def test_create_entry(hass: HomeAssistant) -> None: } result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=test_data ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -86,9 +90,15 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: Test when the form should show when user puts existing location in the config gui. Then the form should show with error. """ + # Coordinates as the flow stores them: the schema validates them as floats. first_entry = MockConfigEntry( domain=DOMAIN, - data={"name": "home", CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_ELEVATION: 0}, + data={ + "name": "home", + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + CONF_ELEVATION: 0, + }, ) first_entry.add_to_hass(hass) @@ -100,7 +110,11 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: } result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=test_data ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/midea/snapshots/test_number.ambr b/tests/components/midea/snapshots/test_number.ambr index 80601966d918fd..204619ae77d417 100644 --- a/tests/components/midea/snapshots/test_number.ambr +++ b/tests/components/midea/snapshots/test_number.ambr @@ -194,7 +194,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'number', - 'entity_category': None, + 'entity_category': , 'entity_id': 'number.heat_pump_water_heater_vacation_days', 'has_entity_name': True, 'hidden_by': None, @@ -255,7 +255,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'number', - 'entity_category': None, + 'entity_category': , 'entity_id': 'number.water_drinking_appliance_flushing_days', 'has_entity_name': True, 'hidden_by': None, @@ -316,7 +316,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'number', - 'entity_category': None, + 'entity_category': , 'entity_id': 'number.water_drinking_appliance_leak_water_protection_value', 'has_entity_name': True, 'hidden_by': None, @@ -438,7 +438,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'number', - 'entity_category': None, + 'entity_category': , 'entity_id': 'number.water_drinking_appliance_water_hardness', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/midea/snapshots/test_select.ambr b/tests/components/midea/snapshots/test_select.ambr index 8dda1cbcc6a6c2..e006a7d070c8f5 100644 --- a/tests/components/midea/snapshots/test_select.ambr +++ b/tests/components/midea/snapshots/test_select.ambr @@ -816,7 +816,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': None, + 'entity_category': , 'entity_id': 'select.air_purifier_screen_display', 'has_entity_name': True, 'hidden_by': None, @@ -944,7 +944,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': None, + 'entity_category': , 'entity_id': 'select.humidifier_screen_display', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/midea/snapshots/test_switch.ambr b/tests/components/midea/snapshots/test_switch.ambr index 1c358daa202687..db1d59821b3313 100644 --- a/tests/components/midea/snapshots/test_switch.ambr +++ b/tests/components/midea/snapshots/test_switch.ambr @@ -12,7 +12,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.dehumidifier_pump', 'has_entity_name': True, 'hidden_by': None, @@ -62,7 +62,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.air_conditioner_anion', 'has_entity_name': True, 'hidden_by': None, @@ -112,7 +112,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.air_conditioner_auxiliary_heating', 'has_entity_name': True, 'hidden_by': None, @@ -162,7 +162,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.air_conditioner_outdoor_silent_mode', 'has_entity_name': True, 'hidden_by': None, @@ -212,7 +212,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.air_conditioner_prompt_tone', 'has_entity_name': True, 'hidden_by': None, @@ -262,7 +262,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.air_conditioner_screen_display', 'has_entity_name': True, 'hidden_by': None, @@ -362,7 +362,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.air_conditioner_smart_eye', 'has_entity_name': True, 'hidden_by': None, @@ -412,7 +412,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.air_conditioner_sound', 'has_entity_name': True, 'hidden_by': None, @@ -462,7 +462,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.toilet_child_lock', 'has_entity_name': True, 'hidden_by': None, @@ -512,7 +512,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.heat_pump_wi_fi_controller_disinfect', 'has_entity_name': True, 'hidden_by': None, @@ -562,7 +562,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.heat_pump_wi_fi_controller_tbh', 'has_entity_name': True, 'hidden_by': None, @@ -612,7 +612,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.heat_pump_wi_fi_controller_zone_1_curve', 'has_entity_name': True, 'hidden_by': None, @@ -662,7 +662,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.heat_pump_wi_fi_controller_zone_2_curve', 'has_entity_name': True, 'hidden_by': None, @@ -712,7 +712,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.mdv_wi_fi_controller_auxiliary_heating', 'has_entity_name': True, 'hidden_by': None, @@ -762,7 +762,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.mdv_wi_fi_controller_night_light', 'has_entity_name': True, 'hidden_by': None, @@ -812,7 +812,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': None, + 'entity_category': , 'entity_id': 'switch.heat_pump_auxiliary_heating', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/midea/snapshots/test_time.ambr b/tests/components/midea/snapshots/test_time.ambr index 28306c3af0230b..3fa1e157368742 100644 --- a/tests/components/midea/snapshots/test_time.ambr +++ b/tests/components/midea/snapshots/test_time.ambr @@ -12,7 +12,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'time', - 'entity_category': None, + 'entity_category': , 'entity_id': 'time.water_drinking_appliance_regeneration_time', 'has_entity_name': True, 'hidden_by': None, @@ -62,7 +62,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'time', - 'entity_category': None, + 'entity_category': , 'entity_id': 'time.water_drinking_appliance_regeneration_time', 'has_entity_name': True, 'hidden_by': None, @@ -112,7 +112,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'time', - 'entity_category': None, + 'entity_category': , 'entity_id': 'time.water_drinking_appliance_regeneration_time', 'has_entity_name': True, 'hidden_by': None, @@ -162,7 +162,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'time', - 'entity_category': None, + 'entity_category': , 'entity_id': 'time.water_drinking_appliance_regeneration_time', 'has_entity_name': True, 'hidden_by': None, @@ -212,7 +212,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'time', - 'entity_category': None, + 'entity_category': , 'entity_id': 'time.water_drinking_appliance_regeneration_time', 'has_entity_name': True, 'hidden_by': None, @@ -262,7 +262,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'time', - 'entity_category': None, + 'entity_category': , 'entity_id': 'time.water_drinking_appliance_regeneration_time', 'has_entity_name': True, 'hidden_by': None, @@ -312,7 +312,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'time', - 'entity_category': None, + 'entity_category': , 'entity_id': 'time.water_drinking_appliance_regeneration_time', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/monzo/snapshots/test_sensor.ambr b/tests/components/monzo/snapshots/test_sensor.ambr index 25aab02720f10b..35f5062ca2015e 100644 --- a/tests/components/monzo/snapshots/test_sensor.ambr +++ b/tests/components/monzo/snapshots/test_sensor.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -45,6 +47,7 @@ : 'Data provided by Monzo', : 'monetary', : 'Current Account Balance', + : , : 'GBP', }), 'context': , @@ -117,7 +120,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -157,6 +162,7 @@ : 'Data provided by Monzo', : 'monetary', : 'Current Account Total balance', + : , : 'GBP', }), 'context': , @@ -173,7 +179,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -213,6 +221,7 @@ : 'Data provided by Monzo', : 'monetary', : 'Flex Balance', + : , : 'EUR', }), 'context': , @@ -285,7 +294,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -325,6 +336,7 @@ : 'Data provided by Monzo', : 'monetary', : 'Flex Total balance', + : , : 'EUR', }), 'context': , @@ -341,7 +353,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -381,6 +395,7 @@ : 'Data provided by Monzo', : 'monetary', : 'Savings Balance', + : , : 'USD', }), 'context': , diff --git a/tests/components/qnap/test_sensor.py b/tests/components/qnap/test_sensor.py new file mode 100644 index 00000000000000..45f47b3316eb70 --- /dev/null +++ b/tests/components/qnap/test_sensor.py @@ -0,0 +1,48 @@ +"""Test the QNAP sensors.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.qnap.sensor import _VOLUME_MON_COND, QNAPVolumeSensor + +from .conftest import TEST_SERIAL + + +def _make_coordinator(volumes: dict) -> MagicMock: + """Create a mock coordinator with the given volumes.""" + coordinator = MagicMock() + coordinator.data = { + "system_stats": { + "system": {"name": "Test NAS", "model": "TS-1"}, + "firmware": {"version": "1.0"}, + }, + "volumes": volumes, + } + return coordinator + + +def _volume_sensor(coordinator: MagicMock) -> QNAPVolumeSensor: + """Create a volume percentage sensor.""" + description = next( + desc for desc in _VOLUME_MON_COND if desc.key == "volume_percentage_used" + ) + return QNAPVolumeSensor(coordinator, description, TEST_SERIAL, "Volume 1") + + +@pytest.mark.parametrize( + ("free_size", "total_size", "expected"), + [ + (75, 100, 25.0), + (0, 0, None), + ], +) +def test_volume_percentage_used( + free_size: int, total_size: int, expected: float | None +) -> None: + """Test the volume percentage used sensor.""" + coordinator = _make_coordinator( + {"Volume 1": {"free_size": free_size, "total_size": total_size}} + ) + sensor = _volume_sensor(coordinator) + assert sensor.native_value == expected diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 3d82f891996a32..f233205861637d 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -3,7 +3,7 @@ from collections.abc import Callable import socket from typing import Any -from unittest.mock import PropertyMock, patch +from unittest.mock import patch import pytest @@ -38,8 +38,6 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac -from .conftest import ConfigEntryFactoryType - from tests.common import MockConfigEntry CLIENTS = [{"mac": "00:00:00:00:00:01"}] @@ -109,8 +107,8 @@ async def test_flow_works(hass: HomeAssistant, mock_discovery) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["data_schema"]({CONF_USERNAME: "", CONF_PASSWORD: ""}) == { - CONF_HOST: "unifi", + assert result["data_schema"]({CONF_PASSWORD: ""}) == { + CONF_HOST: "1", CONF_USERNAME: "", CONF_PASSWORD: "", CONF_PORT: 443, @@ -149,8 +147,8 @@ async def test_flow_works_negative_discovery(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["data_schema"]({CONF_USERNAME: "", CONF_PASSWORD: ""}) == { - CONF_HOST: "", + assert result["data_schema"]({CONF_PASSWORD: ""}) == { + CONF_HOST: "unifi", CONF_USERNAME: "", CONF_PASSWORD: "", CONF_PORT: 443, @@ -219,35 +217,6 @@ async def test_flow_raise_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("config_entry_setup") -async def test_flow_aborts_configuration_updated(hass: HomeAssistant) -> None: - """Test config flow aborts since a connected config entry already exists.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - with patch("homeassistant.components.unifi.async_setup_entry") and patch( - "homeassistant.components.unifi.UnifiHub.available", new_callable=PropertyMock - ) as ws_mock: - ws_mock.return_value = False - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "username", - CONF_PASSWORD: "password", - CONF_PORT: 12345, - CONF_VERIFY_SSL: True, - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "configuration_updated" - - @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -320,7 +289,7 @@ async def test_reauth_flow_update_configuration( result = await config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "reconfigure" context = next( flow["context"] @@ -332,20 +301,16 @@ async def test_reauth_flow_update_configuration( "name": config_entry.title, } - with patch( - "homeassistant.components.unifi.UnifiHub.available", new_callable=PropertyMock - ) as ws_mock: - ws_mock.return_value = False - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "new_name", - CONF_PASSWORD: "new_pass", - CONF_PORT: 1234, - CONF_VERIFY_SSL: True, - }, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + CONF_PORT: 1234, + CONF_VERIFY_SSL: True, + }, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -354,20 +319,26 @@ async def test_reauth_flow_update_configuration( assert config_entry.data[CONF_PASSWORD] == "new_pass" -async def test_reauth_flow_update_configuration_on_not_loaded_entry( - hass: HomeAssistant, config_entry_factory: ConfigEntryFactoryType +@pytest.mark.parametrize( + "site_payload", + [ + [ + {"name": "site2", "role": "admin", "desc": "site2 name", "_id": "2"}, + ] + ], +) +async def test_abort_reauth_flow_on_site_id_mismatch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_requests: Callable[[str, str], None], ) -> None: - """Verify reauth flow can update hub configuration on a not loaded entry.""" - with patch( - "homeassistant.components.unifi.get_unifi_api", - side_effect=CannotConnect, - ): - config_entry = await config_entry_factory() + """Verify reauth flow aborts when original site can no longer be found.""" + mock_requests(config_entry.data[CONF_HOST], config_entry.data[CONF_SITE_ID]) result = await config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "reconfigure" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -381,12 +352,88 @@ async def test_reauth_flow_update_configuration_on_not_loaded_entry( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["reason"] == "unknown_site_id" + assert config_entry.data[CONF_SITE_ID] == "site_id" + + +async def test_reconfigure_flow_update_configuration( + hass: HomeAssistant, config_entry_setup: MockConfigEntry +) -> None: + """Verify reconfigure flow can update hub configuration.""" + config_entry = config_entry_setup + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + CONF_PORT: 1234, + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert config_entry.data[CONF_HOST] == "1.2.3.4" assert config_entry.data[CONF_USERNAME] == "new_name" assert config_entry.data[CONF_PASSWORD] == "new_pass" +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (AuthenticationRequired, "faulty_credentials"), + (CannotConnect, "service_unavailable"), + ], +) +@pytest.mark.usefixtures("mock_default_requests") +async def test_reconfigure_flow_retains_user_input_on_error( + hass: HomeAssistant, + config_entry_setup: MockConfigEntry, + side_effect: type[Exception], + error: str, +) -> None: + """Verify reconfigure flow can update hub configuration.""" + config_entry = config_entry_setup + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + user_input = { + CONF_HOST: "4.3.2.1", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + CONF_PORT: 4321, + CONF_VERIFY_SSL: True, + } + + with patch( + "homeassistant.components.unifi.config_flow.get_unifi_api", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + assert result["data_schema"]({CONF_PASSWORD: ""}) == { + CONF_HOST: user_input[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: "", + CONF_PORT: user_input[CONF_PORT], + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + } + + @pytest.mark.parametrize( "site_payload", [ @@ -395,18 +442,18 @@ async def test_reauth_flow_update_configuration_on_not_loaded_entry( ] ], ) -async def test_abort_reauth_flow_on_site_id_mismatch( +async def test_abort_reconfigure_flow_on_site_id_mismatch( hass: HomeAssistant, config_entry: MockConfigEntry, mock_requests: Callable[[str, str], None], ) -> None: - """Verify reauth flow aborts when original site can no longer be found.""" + """Verify reconfigure flow aborts when original site can no longer be found.""" mock_requests(config_entry.data[CONF_HOST], config_entry.data[CONF_SITE_ID]) - result = await config_entry.start_reauth_flow(hass) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "reconfigure" result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 80e65b8651f9cf..158bce8f455e74 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -32,6 +32,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory, @@ -402,8 +403,44 @@ "index": 1, } ], + "vbms_table": { + "battpool": { + "batteryLevel": 100, + "device_input_voltage": 121.9, + "device_output_current": 0.35, + "device_output_voltage": 121.7, + "device_total_power_factor": 0.98, + "device_total_power_output": 42.5, + "timeToRemain": 30600, + } + }, + } +) + +UPS_DEVICE_2 = deepcopy(UPS_DEVICE_1) +UPS_DEVICE_2.update( + { + "device_id": "mock-ups-2", + "mac": "02:00:00:00:00:02", + "model": "USWDA25", + "name": "Dummy UPS 2U", + "type": "usw", + "outlet_table": [ + { + "index": index, + "relay_state": True, + "cycle_enabled": False, + "name": f"Outlet {index}", + "outlet_caps": caps, + } + for index, caps in enumerate( + (65549, 65549, 65549, 65549, 65541, 65541, 65541, 65541), start=1 + ) + ], } ) +UPS_DEVICE_2["vbms_table"]["battpool"].pop("device_input_voltage") +UPS_DEVICE_2["vbms_table"]["battpool"]["device_bypass_voltage"] = 121.7 @pytest.mark.parametrize( @@ -1020,6 +1057,87 @@ async def test_outlet_power_reading_extended_caps( assert hass.states.get(entity_id).state == "43.5" +@pytest.mark.parametrize( + ( + "device_payload", + "device_name", + "voltage_sensor", + "voltage_value", + "missing_voltage_sensor", + "metered_outlets", + ), + [ + pytest.param( + [UPS_DEVICE_1], + "dummy_ups_2u_pro", + "input_voltage", + "121.9", + "bypass_voltage", + (1, 2), + id="input_voltage", + ), + pytest.param( + [UPS_DEVICE_2], + "dummy_ups_2u", + "bypass_voltage", + "121.7", + "input_voltage", + (), + id="bypass_voltage", + ), + ], +) +@pytest.mark.usefixtures("config_entry_setup") +async def test_ups_battery_pool_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_websocket_message: WebsocketMessageMock, + device_payload: list[dict[str, Any]], + device_name: str, + voltage_sensor: str, + voltage_value: str, + missing_voltage_sensor: str, + metered_outlets: tuple[int, ...], +) -> None: + """Test UPS battery pool telemetry sensors.""" + assert { + entity_id + for entity_id in entity_registry.entities + if entity_id.startswith(f"sensor.{device_name}_outlet_") + and entity_id.endswith("_outlet_power") + } == { + f"sensor.{device_name}_outlet_{index}_outlet_power" for index in metered_outlets + } + assert hass.states.get(f"sensor.{device_name}_battery_level").state == "100" + assert hass.states.get(f"sensor.{device_name}_battery_runtime").state == "30600" + assert hass.states.get(f"sensor.{device_name}_output_power").state == "42.5" + assert hass.states.get(f"sensor.{device_name}_output_current").state == "0.35" + assert hass.states.get(f"sensor.{device_name}_output_voltage").state == "121.7" + assert ( + hass.states.get(f"sensor.{device_name}_{voltage_sensor}").state == voltage_value + ) + assert hass.states.get(f"sensor.{device_name}_{missing_voltage_sensor}") is None + power_factor = hass.states.get(f"sensor.{device_name}_output_power_factor") + assert power_factor is not None + assert power_factor.state == "0.98" + assert power_factor.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER_FACTOR + assert power_factor.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + + updated_device_data = deepcopy(device_payload[0]) + updated_device_data["vbms_table"]["battpool"]["batteryLevel"] = 95 + mock_websocket_message(message=MessageKey.DEVICE, data=updated_device_data) + await hass.async_block_till_done() + + assert hass.states.get(f"sensor.{device_name}_battery_level").state == "95" + + updated_device_data["vbms_table"].pop("battpool") + mock_websocket_message(message=MessageKey.DEVICE, data=updated_device_data) + await hass.async_block_till_done() + + assert hass.states.get(f"sensor.{device_name}_battery_level") is None + assert entity_registry.async_get(f"sensor.{device_name}_battery_level") is None + + @pytest.mark.parametrize( "device_payload", [ diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 19b919b61ea4d8..716614cf06ae1f 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "End-to-end browser tests for Home Assistant Core", "private": true, - "packageManager": "pnpm@12.4.0", + "packageManager": "pnpm@12.4.1", "scripts": { "test": "playwright test" }, diff --git a/tests/e2e/pnpm-lock.yaml b/tests/e2e/pnpm-lock.yaml index e27ae8fce77b8d..eeaf17a148a45e 100644 --- a/tests/e2e/pnpm-lock.yaml +++ b/tests/e2e/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.4.0 - version: 12.4.0 + specifier: 12.4.1 + version: 12.4.1 packages: - '@pnpm/exe.android-arm64@12.4.0': - resolution: {integrity: sha512-sAwslzCw74OpqK2P9l39cgdrRWHSqf5wEjB58JEzeVX6wdLvaHyg9i/GeRK5Ou6PZNA3AkD4yLO3c/y7UqOf8w==} + '@pnpm/exe.android-arm64@12.4.1': + resolution: {integrity: sha512-/HwsqXMSmlOfgtV9+O0ratzjV6Vd/8n1hh4rGHpCGmDURvt52MwZxdbhyxP4K03ZfvgSDvotFPr8O+RzE5Eu8A==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.4.0': - resolution: {integrity: sha512-Ps3Gz0OrqYjuRfhzeNqcvIow6QmNrU3BSzMxJu5rUCSl7inVUUFX2gZbNL9naQsNLoorKCdxUf4N+WI9Mr1WBg==} + '@pnpm/exe.android-x64@12.4.1': + resolution: {integrity: sha512-+l74Qb4c2YjOzNKHXJLg+1wr8xHM1ckkUhnU5KRUK9TJqiiczt6yeTZqqzHIlJ8i6pAoj5V0OJevDRwnQKLgrQ==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.4.0': - resolution: {integrity: sha512-75EYiF8GuiTsnvP4cbZsviMBjohXUYtg2zjD4gdfhqxlU1y9Nj+KdEsbH4jfgB/FgyJSYPB2rqfmvvgLnRlVug==} + '@pnpm/exe.darwin-arm64@12.4.1': + resolution: {integrity: sha512-6rkZkT3iGfaxknUdGHraqSWFvTa6N0ajAHluv9Ax0GRWs0sIcGNiFhDopv6xSZCsJZmG483aNS/b6UEDy3blfw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.4.0': - resolution: {integrity: sha512-b74TzBg8lxl0lqZImGOXpRbAGcqVKX+UMckl3wG+KH52yYHI86VBJP86HbJX30HJ/EFeC6Tgbz2E4b5hhKRvdA==} + '@pnpm/exe.darwin-x64@12.4.1': + resolution: {integrity: sha512-Vb1CHlR88HghC1qUxjxjs82zQSXnXacPD+btG2CmG8Q/hBU7Q0/b2YWJKSQqZXicunV5khFtfTlAwJddV9RkYA==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.4.0': - resolution: {integrity: sha512-A45d6axdQIFNyNTYZAC64wh5+6LJ6dNKhNAoNMNi/EC5y9CRfv0GL3ZYDBqpkmsN9KMAkVsFWizq/Ng6xtjnhw==} + '@pnpm/exe.freebsd-x64@12.4.1': + resolution: {integrity: sha512-iT3iHz3Nl0Sxxj7UPOtZ/aQ81AFsQhjoeWMAlPkSRO04gsgDGAXv3UYxOFaesMWsfNxaGn0A+CITbuCHFF66FA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.4.0': - resolution: {integrity: sha512-yS6DNel7twfEUoW1yka1hpQrnhRe/s1JZ1MThVfgrmNQrNaPyHxQvAihm/GtL2CM6OeMV6z5532H/W/yDjQp5g==} + '@pnpm/exe.linux-arm64-musl@12.4.1': + resolution: {integrity: sha512-aBooZfNXM5f+OGUgCAMFWpE/kAhsWfvmqIyMtHy6zl3aNxyInWrcY/Saln/UElzo7lZWMC0Yktroxd/2J26lNQ==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.4.0': - resolution: {integrity: sha512-6npQUwq3D/WXbTgR4evApEKQ9ITznZ6Iz/dptcjBzA7+wSLTaYkK98Od2wsHCoPmEFb9tGtM+cvG82+wy0o9uA==} + '@pnpm/exe.linux-arm64@12.4.1': + resolution: {integrity: sha512-TlOdacTTP09BgcMvwWBFRsu8VAjfwqwnslBj+XSq1JFM3ck4f3k+1O/747EuctxZxs3/o785b6Q3s7Pd92Ptsg==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.4.0': - resolution: {integrity: sha512-7shJ4WytyvBEdjrbIDqx2gDAH7sr0HBDooTmnNQ7DlzcLeeGi7Yqir7gJjOTm6s9S1cVnT56IwmqnnwQMP1HTQ==} + '@pnpm/exe.linux-ppc64@12.4.1': + resolution: {integrity: sha512-r/ab/MlIBo75oizUP5ITiziCCrnXz4SJwfErLQ+603AshB+Yq7xTMCoMtzTSCAMu6aaymIKkAFtZvd2J75Wq0w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.4.0': - resolution: {integrity: sha512-q03EGUFoe/oOU/67E3sEAePr3qjFu8xrsX+WOXTodcFXfO5FondC6TPs7BSwhTiV27j3jeDP56qhJP05pXn+vw==} + '@pnpm/exe.linux-riscv64@12.4.1': + resolution: {integrity: sha512-C/D1QWdKMiB8+wv/spl1rGITFUuqC+aI/fb6h8NB5sqxsOaGXeYc/g891oCuzggHn6SODk9p+I09hI76x/cMNw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.4.0': - resolution: {integrity: sha512-GZ5YellCtNnaLe9zVj9l3avlw9CbSzExUFDh7v+tVbLfOOfkkRLpx2lsw1ytJ/nFWvxF2TO6M6Q9JDT7q8svRg==} + '@pnpm/exe.linux-s390x@12.4.1': + resolution: {integrity: sha512-nxz5zD4yXt94uzbStDk0QTPKW+aE92hH1b5tFXK9ctB1HE9Xcq1vjTiA2LsZMFC6p4gdu5OwQeFqYNAVGa6QlA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.4.0': - resolution: {integrity: sha512-c8YyjVL39L48tRg9i3iw3d90fN6q84UjxlgWBhplcBOpzCgmglDVkPMtEAVDC+t9iobvYzs2hJnmTqLaA87MVA==} + '@pnpm/exe.linux-x64-musl@12.4.1': + resolution: {integrity: sha512-5AwgFdGhVUg2kIweYGfxzSLEHiIG77PQhZAkXC3TwofQHsu1Wr+TrV5/rNX2PopFnHRzuE581zoB8F6Wle32yg==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.4.0': - resolution: {integrity: sha512-SQVgRkcR4Xyqf8+VNbtY0rtcEnfDq48RhH30HWo2/UfqKEfle2rOMyGZOmN1DbMw4ZzG5mWYoC81O7ZqHFZcPw==} + '@pnpm/exe.linux-x64@12.4.1': + resolution: {integrity: sha512-FJOZuuuQMhp0oLzBtcKkLXknBI92hfkmSnlKc47vfin4HrmfID5khY2lGekL9tCzk1cpR+HShEw/meFl+nHtzQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.4.0': - resolution: {integrity: sha512-ZamXPhx0X6APZJAIkcH+K9KAsKcTYwxEgOyTQ/NXE+2UHBT9bRnSQ91sBeY6ELNd58V5cmMAkXSW5xmU+2ry+w==} + '@pnpm/exe.win32-arm64@12.4.1': + resolution: {integrity: sha512-OO7eKBL9S+xk5hRy+JUUZSNJknGuGe3GEUffsrlC2LbqKF02g+VX1anGIzSbJDvkkdnpJhSlxF5AUz2JzJu2Jw==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.4.0': - resolution: {integrity: sha512-b8bLaprnpYi/2zN0M3H9RDAHuxCP3fmV5agPPwdp9fIdjNSUkV7V/RC3L4oWwbZvVgYEjjFLx1RuJOdltoKP6g==} + '@pnpm/exe.win32-x64@12.4.1': + resolution: {integrity: sha512-x7gJHZgHo6hp354xCYA2NvoFzYJkHwovt2kVsUBK5EmXULLcP51JGMq09CX+5FRFJUZFKoXjLu3mZWmfP7o6PQ==} cpu: [x64] os: [win32] - pnpm@12.4.0: - resolution: {integrity: sha512-N1NsJu1Aq0E0tlEeCfayfz67RWh0aPJAbKOAUnmk5coVjBkxNQrZd01qshCNcbPbrrOZQxWSlDdeTQU+jgVoXA==} + pnpm@12.4.1: + resolution: {integrity: sha512-LoHjmdc/6DkNqyXgaqeIq3pZCCSNL1o3D4K0gRR6ano2e/gEj5pv22Rg8hpm8FQt7bi5TKLIcjWWdBkgsWVtTA==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.4.0': + '@pnpm/exe.android-arm64@12.4.1': optional: true - '@pnpm/exe.android-x64@12.4.0': + '@pnpm/exe.android-x64@12.4.1': optional: true - '@pnpm/exe.darwin-arm64@12.4.0': + '@pnpm/exe.darwin-arm64@12.4.1': optional: true - '@pnpm/exe.darwin-x64@12.4.0': + '@pnpm/exe.darwin-x64@12.4.1': optional: true - '@pnpm/exe.freebsd-x64@12.4.0': + '@pnpm/exe.freebsd-x64@12.4.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.4.0': + '@pnpm/exe.linux-arm64-musl@12.4.1': optional: true - '@pnpm/exe.linux-arm64@12.4.0': + '@pnpm/exe.linux-arm64@12.4.1': optional: true - '@pnpm/exe.linux-ppc64@12.4.0': + '@pnpm/exe.linux-ppc64@12.4.1': optional: true - '@pnpm/exe.linux-riscv64@12.4.0': + '@pnpm/exe.linux-riscv64@12.4.1': optional: true - '@pnpm/exe.linux-s390x@12.4.0': + '@pnpm/exe.linux-s390x@12.4.1': optional: true - '@pnpm/exe.linux-x64-musl@12.4.0': + '@pnpm/exe.linux-x64-musl@12.4.1': optional: true - '@pnpm/exe.linux-x64@12.4.0': + '@pnpm/exe.linux-x64@12.4.1': optional: true - '@pnpm/exe.win32-arm64@12.4.0': + '@pnpm/exe.win32-arm64@12.4.1': optional: true - '@pnpm/exe.win32-x64@12.4.0': + '@pnpm/exe.win32-x64@12.4.1': optional: true - pnpm@12.4.0: + pnpm@12.4.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.4.0 - '@pnpm/exe.android-x64': 12.4.0 - '@pnpm/exe.darwin-arm64': 12.4.0 - '@pnpm/exe.darwin-x64': 12.4.0 - '@pnpm/exe.freebsd-x64': 12.4.0 - '@pnpm/exe.linux-arm64': 12.4.0 - '@pnpm/exe.linux-arm64-musl': 12.4.0 - '@pnpm/exe.linux-ppc64': 12.4.0 - '@pnpm/exe.linux-riscv64': 12.4.0 - '@pnpm/exe.linux-s390x': 12.4.0 - '@pnpm/exe.linux-x64': 12.4.0 - '@pnpm/exe.linux-x64-musl': 12.4.0 - '@pnpm/exe.win32-arm64': 12.4.0 - '@pnpm/exe.win32-x64': 12.4.0 + '@pnpm/exe.android-arm64': 12.4.1 + '@pnpm/exe.android-x64': 12.4.1 + '@pnpm/exe.darwin-arm64': 12.4.1 + '@pnpm/exe.darwin-x64': 12.4.1 + '@pnpm/exe.freebsd-x64': 12.4.1 + '@pnpm/exe.linux-arm64': 12.4.1 + '@pnpm/exe.linux-arm64-musl': 12.4.1 + '@pnpm/exe.linux-ppc64': 12.4.1 + '@pnpm/exe.linux-riscv64': 12.4.1 + '@pnpm/exe.linux-s390x': 12.4.1 + '@pnpm/exe.linux-x64': 12.4.1 + '@pnpm/exe.linux-x64-musl': 12.4.1 + '@pnpm/exe.win32-arm64': 12.4.1 + '@pnpm/exe.win32-x64': 12.4.1 --- lockfileVersion: '9.0'