diff --git a/homeassistant/components/actron_air/config_flow.py b/homeassistant/components/actron_air/config_flow.py index e03b6bbdebd4b..8b5a6f4bc5a83 100644 --- a/homeassistant/components/actron_air/config_flow.py +++ b/homeassistant/components/actron_air/config_flow.py @@ -6,7 +6,12 @@ from actron_neo_api import ActronAirAPI, ActronAirAuthError -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_API_TOKEN from homeassistant.exceptions import HomeAssistantError @@ -105,6 +110,14 @@ async def async_step_finish_login( data_updates={CONF_API_TOKEN: self._api.refresh_token_value}, ) + # Check if this is a reconfigure flow + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_API_TOKEN: self._api.refresh_token_value}, + ) + self._abort_if_unique_id_configured() return self.async_create_entry( title=user_data.email, @@ -138,6 +151,20 @@ async def async_step_reauth_confirm( return self.async_show_form(step_id="reauth_confirm") + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration request.""" + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reconfiguration dialog.""" + if user_input is not None: + return await self.async_step_user() + return self.async_show_form(step_id="reconfigure_confirm") + async def async_step_connection_error( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/actron_air/quality_scale.yaml b/homeassistant/components/actron_air/quality_scale.yaml index c74421b177e4d..4d8dd96fa864d 100644 --- a/homeassistant/components/actron_air/quality_scale.yaml +++ b/homeassistant/components/actron_air/quality_scale.yaml @@ -60,7 +60,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: This integration does not have any known issues that require repair. diff --git a/homeassistant/components/actron_air/strings.json b/homeassistant/components/actron_air/strings.json index fc17e510e998b..cf7c2fc677b50 100644 --- a/homeassistant/components/actron_air/strings.json +++ b/homeassistant/components/actron_air/strings.json @@ -4,7 +4,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "oauth2_error": "Failed to start authentication flow", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured." + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_account": "You must authenticate with the same Actron Air account that was originally configured." }, "error": { "oauth2_error": "Failed to start authentication flow. Please try again later." @@ -22,6 +23,10 @@ "description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.", "title": "Authentication expired" }, + "reconfigure_confirm": { + "description": "Reconfigure your Actron Air account. You will be prompted to log in again. Note: you must use the same account that was originally configured.", + "title": "Reconfigure Actron Air" + }, "timeout": { "data": {}, "description": "The authentication process timed out. Please try again.", diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index 14cddf41e45af..8570c4b29e188 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -1,6 +1,6 @@ """BleBox sensor entities.""" -from datetime import datetime +from datetime import datetime, timedelta import blebox_uniapi.sensor @@ -30,6 +30,9 @@ from . import BleBoxConfigEntry from .entity import BleBoxEntity +SCAN_INTERVAL = timedelta(seconds=5) + + SENSOR_TYPES = ( SensorEntityDescription( key="pm1", @@ -53,9 +56,9 @@ ), SensorEntityDescription( key="powerConsumption", - device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + icon="mdi:lightning-bolt", ), SensorEntityDescription( key="humidity", @@ -150,6 +153,7 @@ def native_value(self): @property def last_reset(self) -> datetime | None: """Return the time when the sensor was last reset, if implemented.""" + if self.state_class != SensorStateClass.TOTAL: + return None native_implementation = getattr(self._feature, "last_reset", None) - return native_implementation or super().last_reset diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index b2177acb52f0c..b8ab566aba7bf 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["bring_api"], "quality_scale": "platinum", - "requirements": ["bring-api==1.1.1"] + "requirements": ["bring-api==1.1.2"] } diff --git a/homeassistant/components/casper_glow/manifest.json b/homeassistant/components/casper_glow/manifest.json index 1e862beae69b2..f1633138fc41d 100644 --- a/homeassistant/components/casper_glow/manifest.json +++ b/homeassistant/components/casper_glow/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pycasperglow"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["pycasperglow==1.2.0"] } diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml index 5e2053ed86f56..f35784fffb35b 100644 --- a/homeassistant/components/casper_glow/quality_scale.yaml +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -45,12 +45,12 @@ rules: comment: No network discovery. discovery: done docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: status: exempt comment: Each config entry represents a single device. diff --git a/homeassistant/components/dnsip/strings.json b/homeassistant/components/dnsip/strings.json index a841cdffcde0b..24da52f2182b4 100644 --- a/homeassistant/components/dnsip/strings.json +++ b/homeassistant/components/dnsip/strings.json @@ -9,11 +9,18 @@ "step": { "user": { "data": { - "hostname": "The hostname for which to perform the DNS query", - "port": "Port for IPV4 lookup", - "port_ipv6": "Port for IPV6 lookup", - "resolver": "Resolver for IPV4 lookup", - "resolver_ipv6": "Resolver for IPV6 lookup" + "hostname": "Hostname", + "port": "IPv4 port", + "port_ipv6": "IPv6 port", + "resolver": "IPv4 resolver", + "resolver_ipv6": "IPv6 resolver" + }, + "data_description": { + "hostname": "The hostname for which to perform the DNS query.", + "port": "Port used for the IPv4 lookup.", + "port_ipv6": "Port used for the IPv6 lookup.", + "resolver": "Resolver used for the IPv4 lookup.", + "resolver_ipv6": "Resolver used for the IPv6 lookup." } } } @@ -50,6 +57,12 @@ "port_ipv6": "[%key:component::dnsip::config::step::user::data::port_ipv6%]", "resolver": "[%key:component::dnsip::config::step::user::data::resolver%]", "resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]" + }, + "data_description": { + "port": "[%key:component::dnsip::config::step::user::data_description::port%]", + "port_ipv6": "[%key:component::dnsip::config::step::user::data_description::port_ipv6%]", + "resolver": "[%key:component::dnsip::config::step::user::data_description::resolver%]", + "resolver_ipv6": "[%key:component::dnsip::config::step::user::data_description::resolver_ipv6%]" } } } diff --git a/homeassistant/components/fluss/button.py b/homeassistant/components/fluss/button.py index 7b2009fe04c84..ab238396eb75e 100644 --- a/homeassistant/components/fluss/button.py +++ b/homeassistant/components/fluss/button.py @@ -29,6 +29,11 @@ class FlussButton(FlussEntity, ButtonEntity): _attr_name = None + @property + def available(self) -> bool: + """Return True only when the device is online.""" + return super().available and self.device["internetConnected"] + async def async_press(self) -> None: """Handle the button press.""" try: diff --git a/homeassistant/components/fluss/const.py b/homeassistant/components/fluss/const.py index b66ae7361065d..d4480136341d7 100644 --- a/homeassistant/components/fluss/const.py +++ b/homeassistant/components/fluss/const.py @@ -5,5 +5,4 @@ DOMAIN = "fluss" LOGGER = logging.getLogger(__name__) -UPDATE_INTERVAL = 60 # seconds -UPDATE_INTERVAL_TIMEDELTA = timedelta(seconds=UPDATE_INTERVAL) +UPDATE_INTERVAL = timedelta(minutes=30) diff --git a/homeassistant/components/fluss/coordinator.py b/homeassistant/components/fluss/coordinator.py index b8a33dd8b1260..a3e9dcdab781c 100644 --- a/homeassistant/components/fluss/coordinator.py +++ b/homeassistant/components/fluss/coordinator.py @@ -1,5 +1,8 @@ """DataUpdateCoordinator for Fluss+ integration.""" +from __future__ import annotations + +import asyncio from typing import Any from fluss_api import ( @@ -15,12 +18,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify -from .const import LOGGER, UPDATE_INTERVAL_TIMEDELTA +from .const import LOGGER, UPDATE_INTERVAL type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator] -class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Manages fetching Fluss device data on a schedule.""" def __init__( @@ -33,11 +36,19 @@ def __init__( LOGGER, name=f"Fluss+ ({slugify(api_key[:8])})", config_entry=config_entry, - update_interval=UPDATE_INTERVAL_TIMEDELTA, + update_interval=UPDATE_INTERVAL, ) + async def _async_get_connectivity(self, device_id: str) -> bool: + """Return connectivity for a device; False if the status call fails.""" + try: + status = await self.api.async_get_device_status(device_id) + except FlussApiClientError: + return False + return status["status"]["internetConnected"] + async def _async_update_data(self) -> dict[str, dict[str, Any]]: - """Fetch data from the Fluss API and return as a dictionary keyed by deviceId.""" + """Fetch Fluss+ devices and merge per-device connectivity status.""" try: devices = await self.api.async_get_devices() except FlussApiClientAuthenticationError as err: @@ -45,4 +56,15 @@ async def _async_update_data(self) -> dict[str, dict[str, Any]]: except FlussApiClientError as err: raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err - return {device["deviceId"]: device for device in devices.get("devices", [])} + device_list = [ + device + for device in devices["devices"] + if device["userPermissions"]["canUseWiFi"] + ] + connectivity = await asyncio.gather( + *(self._async_get_connectivity(d["deviceId"]) for d in device_list) + ) + return { + device["deviceId"]: {**device, "internetConnected": connected} + for device, connected in zip(device_list, connectivity, strict=False) + } diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 236fecf281471..b2290ade9afa4 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta import logging +from fritzconnection.core.exceptions import FritzConnectionException from fritzconnection.lib.fritzstatus import FritzStatus from requests.exceptions import RequestException @@ -143,7 +144,7 @@ def _is_suitable_cpu_temperature(status: FritzStatus) -> bool: """Return whether the CPU temperature sensor is suitable.""" try: cpu_temp = status.get_cpu_temperatures()[0] - except RequestException, IndexError: + except RequestException, IndexError, FritzConnectionException: _LOGGER.debug("CPU temperature not supported by the device") return False if cpu_temp == 0: diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 1376253893550..ca22dbb8e819a 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -117,9 +117,22 @@ async def async_step_2fa( if not errors: _LOGGER.debug("2FA successful") if self.source == SOURCE_REAUTH: - return await self.async_setup_hive_entry() - self.device_registration = True - return await self.async_step_configuration() + try: + device_registered = await self.hive_auth.is_device_registered() + except HiveApiError as err: + _LOGGER.debug( + "Failed to check whether the Hive device is registered during reauthentication: %s", + err, + ) + errors["base"] = "no_internet_available" + else: + if device_registered: + return await self.async_setup_hive_entry() + self.device_registration = True + return await self.async_step_configuration() + else: + self.device_registration = True + return await self.async_step_configuration() schema = vol.Schema({vol.Required(CONF_CODE): str}) return self.async_show_form(step_id="2fa", data_schema=schema, errors=errors) @@ -171,6 +184,7 @@ async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Re Authenticate a user.""" + self.data = dict(entry_data) data = { CONF_USERNAME: entry_data[CONF_USERNAME], CONF_PASSWORD: entry_data[CONF_PASSWORD], diff --git a/homeassistant/components/indevolt/button.py b/homeassistant/components/indevolt/button.py index 4dfa0fd83d781..5346160231581 100644 --- a/homeassistant/components/indevolt/button.py +++ b/homeassistant/components/indevolt/button.py @@ -1,6 +1,6 @@ """Button platform for Indevolt integration.""" -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Final from indevolt_api import IndevoltRealtimeAction @@ -20,7 +20,7 @@ class IndevoltButtonEntityDescription(ButtonEntityDescription): """Custom entity description class for Indevolt button entities.""" - generation: list[int] = field(default_factory=lambda: [1, 2]) + generation: tuple[int, ...] = (1, 2) BUTTONS: Final = ( diff --git a/homeassistant/components/indevolt/number.py b/homeassistant/components/indevolt/number.py index efcd13ce64356..5458245f6d9b7 100644 --- a/homeassistant/components/indevolt/number.py +++ b/homeassistant/components/indevolt/number.py @@ -1,6 +1,6 @@ """Number platform for Indevolt integration.""" -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Final from indevolt_api import IndevoltConfig @@ -27,15 +27,15 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): """Custom entity description class for Indevolt number entities.""" - generation: list[int] = field(default_factory=lambda: [1, 2]) read_key: str write_key: str + generation: tuple[int, ...] = (1, 2) NUMBERS: Final = ( IndevoltNumberEntityDescription( key="discharge_limit", - generation=[2], + generation=(2,), translation_key="discharge_limit", read_key=IndevoltConfig.READ_DISCHARGE_LIMIT, write_key=IndevoltConfig.WRITE_DISCHARGE_LIMIT, @@ -46,7 +46,7 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): ), IndevoltNumberEntityDescription( key="max_ac_output_power", - generation=[2], + generation=(2,), translation_key="max_ac_output_power", read_key=IndevoltConfig.READ_MAX_AC_OUTPUT_POWER, write_key=IndevoltConfig.WRITE_MAX_AC_OUTPUT_POWER, @@ -58,7 +58,7 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): ), IndevoltNumberEntityDescription( key="inverter_input_limit", - generation=[2], + generation=(2,), translation_key="inverter_input_limit", read_key=IndevoltConfig.READ_INVERTER_INPUT_LIMIT, write_key=IndevoltConfig.WRITE_INVERTER_INPUT_LIMIT, @@ -70,7 +70,7 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): ), IndevoltNumberEntityDescription( key="feedin_power_limit", - generation=[2], + generation=(2,), translation_key="feedin_power_limit", read_key=IndevoltConfig.READ_FEEDIN_POWER_LIMIT, write_key=IndevoltConfig.WRITE_FEEDIN_POWER_LIMIT, diff --git a/homeassistant/components/indevolt/select.py b/homeassistant/components/indevolt/select.py index 9db9790ef436d..31b6f09b9d377 100644 --- a/homeassistant/components/indevolt/select.py +++ b/homeassistant/components/indevolt/select.py @@ -25,7 +25,7 @@ class IndevoltSelectEntityDescription(SelectEntityDescription): write_key: str value_to_option: dict[IndevoltEnergyMode, str] unavailable_values: list[IndevoltEnergyMode] = field(default_factory=list) - generation: list[int] = field(default_factory=lambda: [1, 2]) + generation: tuple[int, ...] = (1, 2) SELECTS: Final = ( diff --git a/homeassistant/components/indevolt/switch.py b/homeassistant/components/indevolt/switch.py index a9b11f472193a..203f8ba419552 100644 --- a/homeassistant/components/indevolt/switch.py +++ b/homeassistant/components/indevolt/switch.py @@ -1,6 +1,6 @@ """Switch platform for Indevolt integration.""" -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Final from indevolt_api import IndevoltConfig @@ -29,14 +29,14 @@ class IndevoltSwitchEntityDescription(SwitchEntityDescription): write_key: str read_on_value: int = 1 read_off_value: int = 0 - generation: list[int] = field(default_factory=lambda: [1, 2]) + generation: tuple[int, ...] = (1, 2) SWITCHES: Final = ( IndevoltSwitchEntityDescription( key="grid_charging", translation_key="grid_charging", - generation=[2], + generation=(2,), read_key=IndevoltConfig.READ_GRID_CHARGING, write_key=IndevoltConfig.WRITE_GRID_CHARGING, read_on_value=1001, @@ -46,7 +46,7 @@ class IndevoltSwitchEntityDescription(SwitchEntityDescription): IndevoltSwitchEntityDescription( key="light", translation_key="light", - generation=[2], + generation=(2,), read_key=IndevoltConfig.READ_LIGHT, write_key=IndevoltConfig.WRITE_LIGHT, device_class=SwitchDeviceClass.SWITCH, @@ -54,7 +54,7 @@ class IndevoltSwitchEntityDescription(SwitchEntityDescription): IndevoltSwitchEntityDescription( key="bypass", translation_key="bypass", - generation=[2], + generation=(2,), read_key=IndevoltConfig.READ_BYPASS, write_key=IndevoltConfig.WRITE_BYPASS, device_class=SwitchDeviceClass.SWITCH, diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 159de66b481f6..2cdd47c4f0d34 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -1,7 +1,11 @@ """Device tracker for Mobile app.""" from collections.abc import Callable -from typing import Any +from dataclasses import dataclass +import logging +from typing import Any, Self + +import voluptuous as vol from homeassistant.components.device_tracker import ( ATTR_BATTERY, @@ -23,10 +27,11 @@ STATE_HOME, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from .const import ( ATTR_ALTITUDE, @@ -38,8 +43,49 @@ ) from .helpers import device_info +_LOGGER = logging.getLogger(__name__) + ATTR_KEYS = (ATTR_ALTITUDE, ATTR_COURSE, ATTR_SPEED, ATTR_VERTICAL_ACCURACY) +LOCATION_UPDATE_SCHEMA = vol.All( + cv.key_dependency(ATTR_GPS, ATTR_GPS_ACCURACY), + vol.Schema( + { + vol.Optional(ATTR_LOCATION_NAME): cv.string, + vol.Optional(ATTR_GPS): cv.gps, + vol.Optional(ATTR_GPS_ACCURACY): cv.positive_float, + vol.Optional(ATTR_BATTERY): cv.positive_int, + vol.Optional(ATTR_SPEED): cv.positive_int, + vol.Optional(ATTR_ALTITUDE): vol.Coerce(float), + vol.Optional(ATTR_COURSE): cv.positive_int, + vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int, + }, + ), +) + + +@dataclass +class MobileAppDeviceTrackerExtraStoredData(ExtraStoredData): + """Object to hold mobile app device tracker data to be restored.""" + + data: dict[str, Any] + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the stored data.""" + return {"data": self.data} + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored mobile app entity data from a dict.""" + if (data := restored.get("data")) is None: + return None + try: + validated = LOCATION_UPDATE_SCHEMA(data) + except vol.Invalid as err: + _LOGGER.debug("Discarding invalid restored device tracker data: %s", err) + return None + return cls(validated) + async def async_setup_entry( hass: HomeAssistant, @@ -133,6 +179,18 @@ async def async_added_to_hass(self) -> None: self.update_data, ) + if (extra_data := await self.async_get_last_extra_data()) is not None: + if ( + restored := MobileAppDeviceTrackerExtraStoredData.from_dict( + extra_data.as_dict() + ) + ) is not None: + self._data = restored.data + return + + # Fallback for entities saved before MobileAppDeviceTrackerExtraStoredData + # was introduced: reconstruct from the previous state's attributes. + # This can be removed in HA Core 2026.12. if (state := await self.async_get_last_state()) is None: return @@ -145,6 +203,11 @@ async def async_added_to_hass(self) -> None: data.update({key: attr[key] for key in attr if key in ATTR_KEYS}) self._data = data + @property + def extra_restore_state_data(self) -> MobileAppDeviceTrackerExtraStoredData: + """Return the entity data to be restored.""" + return MobileAppDeviceTrackerExtraStoredData(self._data) + async def async_will_remove_from_hass(self) -> None: """Call when entity is being removed from hass.""" await super().async_will_remove_from_hass() diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index b87c09937de8c..278e62b2c9b01 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -24,11 +24,6 @@ ) from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.camera import CameraEntityFeature -from homeassistant.components.device_tracker import ( - ATTR_BATTERY, - ATTR_GPS, - ATTR_LOCATION_NAME, -) from homeassistant.components.frontend import MANIFEST_JSON from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN @@ -36,7 +31,6 @@ from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_DOMAIN, - ATTR_GPS_ACCURACY, ATTR_SERVICE, ATTR_SERVICE_DATA, ATTR_SUPPORTED_FEATURES, @@ -57,11 +51,9 @@ from homeassistant.util.decorator import Registry from .const import ( - ATTR_ALTITUDE, ATTR_APP_DATA, ATTR_APP_VERSION, ATTR_CAMERA_ENTITY_ID, - ATTR_COURSE, ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, @@ -81,11 +73,9 @@ ATTR_SENSOR_TYPE_SENSOR, ATTR_SENSOR_UNIQUE_ID, ATTR_SENSOR_UOM, - ATTR_SPEED, ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, ATTR_TEMPLATE_VARIABLES, - ATTR_VERTICAL_ACCURACY, ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, @@ -108,6 +98,7 @@ SIGNAL_LOCATION_UPDATE, SIGNAL_SENSOR_UPDATE, ) +from .device_tracker import LOCATION_UPDATE_SCHEMA from .helpers import ( async_is_local_only_user, decrypt_payload, @@ -405,23 +396,7 @@ async def webhook_render_template( @WEBHOOK_COMMANDS.register("update_location") -@validate_schema( - vol.All( - cv.key_dependency(ATTR_GPS, ATTR_GPS_ACCURACY), - vol.Schema( - { - vol.Optional(ATTR_LOCATION_NAME): cv.string, - vol.Optional(ATTR_GPS): cv.gps, - vol.Optional(ATTR_GPS_ACCURACY): cv.positive_float, - vol.Optional(ATTR_BATTERY): cv.positive_int, - vol.Optional(ATTR_SPEED): cv.positive_int, - vol.Optional(ATTR_ALTITUDE): vol.Coerce(float), - vol.Optional(ATTR_COURSE): cv.positive_int, - vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int, - }, - ), - ) -) +@validate_schema(LOCATION_UPDATE_SCHEMA) async def webhook_update_location( hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, Any] ) -> Response: diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py index 9bb041fce6c6a..eed45bdc8f889 100644 --- a/homeassistant/components/nest/event.py +++ b/homeassistant/components/nest/event.py @@ -8,6 +8,7 @@ from google_nest_sdm.traits import TraitType from homeassistant.components.event import ( + DoorbellEventType, EventDeviceClass, EventEntity, EventEntityDescription, @@ -42,7 +43,7 @@ class NestEventEntityDescription(EventEntityDescription): key=EVENT_DOORBELL_CHIME, translation_key="chime", device_class=EventDeviceClass.DOORBELL, - event_types=[EVENT_DOORBELL_CHIME], + event_types=[DoorbellEventType.RING], trait_types=[TraitType.DOORBELL_CHIME], api_event_types=[EventType.DOORBELL_CHIME], ), @@ -80,7 +81,7 @@ async def async_setup_entry( class NestTraitEventEntity(EventEntity): - """Nest doorbell event entity.""" + """Nest event entity for event entity descriptions.""" entity_description: NestEventEntityDescription _attr_has_entity_name = True @@ -113,6 +114,9 @@ async def _async_handle_event(self, event_message: EventMessage) -> None: # This event is a duplicate message in the same thread return + if event_type == EVENT_DOORBELL_CHIME: + event_type = DoorbellEventType.RING + self._trigger_event( event_type, {"nest_event_id": nest_event_id}, diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index e15c7f2dcb7a5..aa4490d03f92c 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -113,7 +113,7 @@ "state_attributes": { "event_type": { "state": { - "doorbell_chime": "[%key:component::nest::entity::event::chime::name%]" + "ring": "[%key:component::event::entity_component::doorbell::state_attributes::event_type::state::ring%]" } } } diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py index d25a01352c2b6..26b591f23c9b5 100644 --- a/homeassistant/components/proxmoxve/config_flow.py +++ b/homeassistant/components/proxmoxve/config_flow.py @@ -96,6 +96,19 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), **auth_kwargs, ) + except AuthenticationError as err: + raise ProxmoxAuthenticationError from err + except SSLError as err: + raise ProxmoxSSLError from err + except ConnectTimeout as err: + raise ProxmoxConnectTimeout from err + except ResourceException as err: + _LOGGER.debug("Error during Proxmox client initialisation", exc_info=True) + raise ProxmoxInitFailed from err + except requests.exceptions.ConnectionError as err: + raise ProxmoxConnectionError from err + + try: nodes = client.nodes.get() except AuthenticationError as err: raise ProxmoxAuthenticationError from err @@ -104,6 +117,7 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: except ConnectTimeout as err: raise ProxmoxConnectTimeout from err except ResourceException as err: + _LOGGER.debug("Error fetching nodes", exc_info=True) raise ProxmoxNoNodesFound from err except requests.exceptions.ConnectionError as err: raise ProxmoxConnectionError from err @@ -120,7 +134,10 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: vms = client.nodes(node["node"]).qemu.get() containers = client.nodes(node["node"]).lxc.get() except ResourceException as err: - raise ProxmoxNoNodesFound from err + _LOGGER.debug( + "Error fetching VMs/LXC for node %s", node["node"], exc_info=True + ) + raise ProxmoxNoVMLXCFound from err except requests.exceptions.ConnectionError as err: raise ProxmoxConnectionError from err @@ -303,9 +320,15 @@ async def _validate_input( except ProxmoxSSLError as exc: errors["base"] = "ssl_error" err = exc + except ProxmoxInitFailed as exc: + errors["base"] = "api_error_no_details" + err = exc except ProxmoxNoNodesFound as exc: errors["base"] = "no_nodes_found" err = exc + except ProxmoxNoVMLXCFound as exc: + errors["base"] = "no_vmlxc_found" + err = exc except ProxmoxConnectionError as exc: errors["base"] = "cannot_connect" err = exc @@ -375,6 +398,14 @@ class ProxmoxNoNodesFound(ProxmoxError): """Error to indicate no nodes found.""" +class ProxmoxNoVMLXCFound(ProxmoxError): + """Error to indicate no LXC or VM found.""" + + +class ProxmoxInitFailed(ProxmoxError): + """Error to indicate API initialisation failure.""" + + class ProxmoxConnectTimeout(ProxmoxError): """Error to indicate a connection timeout.""" diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index a88c366f1fd32..a92e6ef4506fe 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -6,10 +6,12 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { + "api_error_no_details": "An error occurred while communicating with the Proxmox VE instance.", "cannot_connect": "Cannot connect to Proxmox VE server", "connect_timeout": "[%key:common::config_flow::error::timeout_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "no_nodes_found": "No active nodes found", + "no_nodes_found": "No active nodes were found on the Proxmox VE server.", + "no_vmlxc_found": "No LXC or VM were found on the Proxmox VE server.", "ssl_error": "SSL check failed. Check the SSL settings" }, "step": { @@ -324,6 +326,9 @@ "no_permission_vm_lxc_power": { "message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again." }, + "no_vmlxc_found": { + "message": "No LXC or VM were found on the Proxmox VE server." + }, "permissions_error": { "message": "Failed to retrieve Proxmox VE permissions. Please check your credentials and try again." }, diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index c7c2ea469a87c..08690dc8aab2b 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pyuptimerobot"], "quality_scale": "gold", - "requirements": ["pyuptimerobot==24.0.1"] + "requirements": ["pyuptimerobot==25.0.0"] } diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 1ea62696cc1e3..234aede540096 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -59,9 +59,12 @@ class UptimeRobotSensor(UptimeRobotEntity, SensorEntity): """Representation of a UptimeRobot sensor.""" @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Return the status of the monitor.""" + if not self._monitor.status: + return None + status = self._monitor.status.lower() # The API returns "paused" # but the entity state will be "pause" to avoid a breaking change - return {"paused": "pause"}.get(status, status) # type: ignore[no-any-return] + return {"paused": "pause"}.get(status, status) diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index bce8f06141a1c..0520da935059f 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -2,11 +2,7 @@ from typing import Any -from pyuptimerobot import ( - UptimeRobotAuthenticationException, - UptimeRobotException, - UptimeRobotMonitor, -) +from pyuptimerobot import UptimeRobotMonitor from homeassistant.components.switch import ( SwitchDeviceClass, @@ -14,13 +10,12 @@ SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, STATUS_DOWN, STATUS_UP +from .const import STATUS_UP from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity -from .utils import new_device_listener +from .utils import new_device_listener, uptimerobot_api_call # Limit the number of parallel updates to 1 PARALLEL_UPDATES = 1 @@ -63,26 +58,14 @@ def is_on(self) -> bool: """Return True if the entity is on.""" return bool(self._monitor.status == STATUS_UP) - async def _async_edit_monitor(self, **kwargs: Any) -> None: - """Edit monitor status.""" - try: - await self.api.async_edit_monitor(**kwargs) - except UptimeRobotAuthenticationException: - self.coordinator.config_entry.async_start_reauth(self.hass) - return - except UptimeRobotException as exception: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="api_exception", - translation_placeholders={"error": "Generic UptimeRobot exception"}, - ) from exception - - await self.coordinator.async_request_refresh() - + @uptimerobot_api_call async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - await self._async_edit_monitor(monitor_id=self._monitor.id, status=STATUS_DOWN) + await self.api.async_pause_monitor(monitor_id=self._monitor.id) + await self.coordinator.async_request_refresh() + @uptimerobot_api_call async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - await self._async_edit_monitor(monitor_id=self._monitor.id, status=STATUS_UP) + await self.api.async_start_monitor(monitor_id=self._monitor.id) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/uptimerobot/utils.py b/homeassistant/components/uptimerobot/utils.py index 989f481f11b9e..f0ae42ac9acce 100644 --- a/homeassistant/components/uptimerobot/utils.py +++ b/homeassistant/components/uptimerobot/utils.py @@ -1,10 +1,43 @@ """Utility functions for the UptimeRobot integration.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate -from pyuptimerobot import UptimeRobotMonitor +from pyuptimerobot import ( + UptimeRobotAuthenticationException, + UptimeRobotException, + UptimeRobotMonitor, +) +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN from .coordinator import UptimeRobotDataUpdateCoordinator +from .entity import UptimeRobotEntity + + +def uptimerobot_api_call[_T: UptimeRobotEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch UptimeRobot API call exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except UptimeRobotAuthenticationException: + self.coordinator.config_entry.async_start_reauth(self.hass) + return + except UptimeRobotException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_exception", + translation_placeholders={"error": "Generic UptimeRobot exception"}, + ) from exception + + return cmd_wrapper def new_device_listener( diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index be3c3fa98bdfb..d5534899ce84b 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -261,9 +261,21 @@ async def _async_determine_api_calls( api.async_get_odometer, ] - location = await api.async_get_location() + # Volvo is returning FORBIDDEN for the location request in case the vehicle + # is in an unsupported region. Since we can't know where the vehicle is + # located, we silently ignore the failure. If (re-)authentication is needed, + # other requests will fail as well and trigger the re-auth flow. + location = None + try: + location = await api.async_get_location() + except VolvoAuthException as ex: + _LOGGER.debug( + "%s - Location not supported for this vehicle. %s", + self.config_entry.entry_id, + ex.message, + ) - if location.get("location") is not None: + if location and location.get("location") is not None: api_calls.append(api.async_get_location) return api_calls diff --git a/requirements_all.txt b/requirements_all.txt index defa935fae666..53c1ca7d6c079 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -697,7 +697,7 @@ boto3==1.37.1 botocore==1.37.1 # homeassistant.components.bring -bring-api==1.1.1 +bring-api==1.1.2 # homeassistant.components.broadlink broadlink==0.19.0 @@ -2739,7 +2739,7 @@ pytrafikverket==1.1.1 pytrydan==0.8.0 # homeassistant.components.uptimerobot -pyuptimerobot==24.0.1 +pyuptimerobot==25.0.0 # homeassistant.components.vera pyvera==0.3.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 293920da413ca..edf033752b8f0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -627,7 +627,7 @@ boschshcpy==0.2.107 botocore==1.37.1 # homeassistant.components.bring -bring-api==1.1.1 +bring-api==1.1.2 # homeassistant.components.broadlink broadlink==0.19.0 @@ -2338,7 +2338,7 @@ pytrafikverket==1.1.1 pytrydan==0.8.0 # homeassistant.components.uptimerobot -pyuptimerobot==24.0.1 +pyuptimerobot==25.0.0 # homeassistant.components.vera pyvera==0.3.16 diff --git a/tests/components/actron_air/test_config_flow.py b/tests/components/actron_air/test_config_flow.py index 272e4627a3bf2..075a756feded6 100644 --- a/tests/components/actron_air/test_config_flow.py +++ b/tests/components/actron_air/test_config_flow.py @@ -336,3 +336,64 @@ async def test_finish_login_auth_error( # Should abort with oauth2_error assert result["type"] is FlowResultType.ABORT assert result["reason"] == "oauth2_error" + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_actron_api: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test successful reconfiguration flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "wait_for_authorization" + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_TOKEN] == "test_refresh_token" + + +async def test_reconfigure_flow_wrong_account( + hass: HomeAssistant, + mock_actron_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration flow aborts when wrong account is used.""" + mock_config_entry.add_to_hass(hass) + + mock_actron_api.get_user_info = AsyncMock( + return_value=ActronAirUserInfo( + id="different_user_id", email="different@example.com" + ) + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 4c8475428e9e2..bc28943a4e62b 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -242,9 +242,16 @@ 'attributes': list([ dict({ 'content': dict({ + 'activeFrom': None, + 'activeTo': None, + 'companyBrn': None, 'convenient': True, 'discounted': True, + 'newItemIcon': None, + 'newItemSection': None, + 'source': None, 'urgent': True, + 'uuid': None, }), 'type': 'PURCHASE_CONDITIONS', }), @@ -257,9 +264,16 @@ 'attributes': list([ dict({ 'content': dict({ + 'activeFrom': None, + 'activeTo': None, + 'companyBrn': None, 'convenient': True, 'discounted': True, + 'newItemIcon': None, + 'newItemSection': None, + 'source': None, 'urgent': True, + 'uuid': None, }), 'type': 'PURCHASE_CONDITIONS', }), @@ -296,9 +310,16 @@ 'attributes': list([ dict({ 'content': dict({ + 'activeFrom': None, + 'activeTo': None, + 'companyBrn': None, 'convenient': True, 'discounted': True, + 'newItemIcon': None, + 'newItemSection': None, + 'source': None, 'urgent': True, + 'uuid': None, }), 'type': 'PURCHASE_CONDITIONS', }), @@ -311,9 +332,16 @@ 'attributes': list([ dict({ 'content': dict({ + 'activeFrom': None, + 'activeTo': None, + 'companyBrn': None, 'convenient': True, 'discounted': True, + 'newItemIcon': None, + 'newItemSection': None, + 'source': None, 'urgent': True, + 'uuid': None, }), 'type': 'PURCHASE_CONDITIONS', }), diff --git a/tests/components/fluss/conftest.py b/tests/components/fluss/conftest.py index b585217113db6..a9474bed2edf6 100644 --- a/tests/components/fluss/conftest.py +++ b/tests/components/fluss/conftest.py @@ -46,8 +46,19 @@ def mock_api_client() -> Generator[AsyncMock]: client = mock_client.return_value client.async_get_devices.return_value = { "devices": [ - {"deviceId": "2a303030sdj1", "deviceName": "Device 1"}, - {"deviceId": "ape93k9302j2", "deviceName": "Device 2"}, + { + "deviceId": "2a303030sdj1", + "deviceName": "Device 1", + "userPermissions": {"canUseWiFi": True}, + }, + { + "deviceId": "ape93k9302j2", + "deviceName": "Device 2", + "userPermissions": {"canUseWiFi": True}, + }, ] } + client.async_get_device_status.return_value = { + "status": {"internetConnected": True} + } yield client diff --git a/tests/components/fluss/test_button.py b/tests/components/fluss/test_button.py index de37b3e630f57..f0d8ef811f01a 100644 --- a/tests/components/fluss/test_button.py +++ b/tests/components/fluss/test_button.py @@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -48,6 +48,66 @@ async def test_button_press( mock_api_client.async_trigger_device.assert_called_once_with("2a303030sdj1") +async def test_devices_without_wifi_permission_are_filtered( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Devices whose userPermissions.canUseWiFi is false must not be surfaced.""" + mock_api_client.async_get_devices.return_value = { + "devices": [ + { + "deviceId": "allowed", + "deviceName": "Allowed", + "userPermissions": {"canUseWiFi": True}, + }, + { + "deviceId": "blocked", + "deviceName": "Blocked", + "userPermissions": {"canUseWiFi": False}, + }, + ] + } + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("button.allowed") is not None + assert hass.states.get("button.blocked") is None + mock_api_client.async_get_device_status.assert_called_once_with("allowed") + + +async def test_button_unavailable_on_status_error( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Buttons become unavailable when the status call errors.""" + mock_api_client.async_get_device_status.side_effect = FlussApiClientError( + "device offline" + ) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("button.device_1").state == STATE_UNAVAILABLE + assert hass.states.get("button.device_2").state == STATE_UNAVAILABLE + + +async def test_button_unavailable_when_internet_disconnected( + hass: HomeAssistant, + mock_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Buttons become unavailable when the device reports no internet.""" + mock_api_client.async_get_device_status.return_value = { + "status": {"internetConnected": False} + } + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("button.device_1").state == STATE_UNAVAILABLE + assert hass.states.get("button.device_2").state == STATE_UNAVAILABLE + + async def test_button_press_error( hass: HomeAssistant, mock_api_client: FlussApiClient, diff --git a/tests/components/fluss/test_init.py b/tests/components/fluss/test_init.py index e7f6b3691dee0..09a9c577fb72c 100644 --- a/tests/components/fluss/test_init.py +++ b/tests/components/fluss/test_init.py @@ -10,6 +10,7 @@ import pytest from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import setup_integration @@ -54,3 +55,19 @@ async def test_async_setup_entry_authentication_error( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is state + + +async def test_status_authentication_error_marks_device_offline( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: AsyncMock, +) -> None: + """Test that an auth error from a per-device status call marks the device offline.""" + mock_api_client.async_get_device_status.side_effect = ( + FlussApiClientAuthenticationError("permission revoked") + ) + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert hass.states.get("button.device_1").state == STATE_UNAVAILABLE + assert hass.states.get("button.device_2").state == STATE_UNAVAILABLE diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index ef08e3891a7ff..4df192c5403d3 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -2573,6 +2573,864 @@ 'state': '2024-08-03T16:30:21+00:00', }) # --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_connection_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_connection_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Connection uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection uptime', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_uptime', + 'unique_id': '1CED6F123411-connection_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_connection_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'uptime', + 'friendly_name': 'Mock Title Connection uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_title_connection_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-01T10:11:33+00:00', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kb_s_received', + 'unique_id': '1CED6F123411-kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Download throughput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67.6', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_external_ip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_external_ip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External IP', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External IP', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_ip', + 'unique_id': '1CED6F123411-external_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_external_ip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title External IP', + }), + 'context': , + 'entity_id': 'sensor.mock_title_external_ip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2.3.4', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_external_ipv6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_external_ipv6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External IPv6', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External IPv6', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_ipv6', + 'unique_id': '1CED6F123411-external_ipv6', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_external_ipv6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title External IPv6', + }), + 'context': , + 'entity_id': 'sensor.mock_title_external_ipv6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fec0::1', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_gb_received-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_gb_received', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'GB received', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GB received', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gb_received', + 'unique_id': '1CED6F123411-gb_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_gb_received-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title GB received', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_gb_received', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.2', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_gb_sent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_gb_sent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'GB sent', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GB sent', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gb_sent', + 'unique_id': '1CED6F123411-gb_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_gb_sent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title GB sent', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_gb_sent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.7', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_download_noise_margin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_noise_margin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download noise margin', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link download noise margin', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_noise_margin_received', + 'unique_id': '1CED6F123411-link_noise_margin_received', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_download_noise_margin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link download noise margin', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_noise_margin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_download_power_attenuation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_power_attenuation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download power attenuation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link download power attenuation', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_attenuation_received', + 'unique_id': '1CED6F123411-link_attenuation_received', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_download_power_attenuation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link download power attenuation', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_power_attenuation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Link download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_kb_s_received', + 'unique_id': '1CED6F123411-link_kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Link download throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '318557.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_upload_noise_margin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_noise_margin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload noise margin', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link upload noise margin', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_noise_margin_sent', + 'unique_id': '1CED6F123411-link_noise_margin_sent', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_upload_noise_margin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link upload noise margin', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_noise_margin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_upload_power_attenuation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload power attenuation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link upload power attenuation', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_attenuation_sent', + 'unique_id': '1CED6F123411-link_attenuation_sent', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_upload_power_attenuation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link upload power attenuation', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Link upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_kb_s_sent', + 'unique_id': '1CED6F123411-link_kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_link_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Link upload throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51805.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_max_connection_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_max_connection_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Max connection download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max connection download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_kb_s_received', + 'unique_id': '1CED6F123411-max_kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_max_connection_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Max connection download throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_max_connection_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10087.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_max_connection_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Max connection upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max connection upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_kb_s_sent', + 'unique_id': '1CED6F123411-max_kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_max_connection_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Max connection upload throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2105.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kb_s_sent', + 'unique_id': '1CED6F123411-kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Upload throughput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.4', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1CED6F123411-device_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect3-None][sensor.mock_title_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'uptime', + 'friendly_name': 'Mock Title Uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_title_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-08-03T16:30:21+00:00', + }) +# --- # name: test_sensor_setup[sensor.mock_title_connection_uptime-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 50f516633dbb1..e574faf346d36 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -117,7 +117,12 @@ async def test_sensor_uptime_spike( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("side_effect", "return_values"), - [(RequestException("boom"), None), (None, [0, 0, 0]), (None, [])], + [ + (RequestException("boom"), None), + (None, [0, 0, 0]), + (None, []), + (FritzConnectionException("boom"), None), + ], ) async def test_sensor_cpu_temp_not_supported( hass: HomeAssistant, diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index 812585173b773..fdfece06dd9d2 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -50,15 +50,15 @@ async def test_user_flow(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == USERNAME - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"] == { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, "tokens": { @@ -97,7 +97,7 @@ async def test_user_flow_with_no_2fa(hass: HomeAssistant) -> None: }, }, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_USERNAME: USERNAME, @@ -105,9 +105,9 @@ async def test_user_flow_with_no_2fa(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "configuration" - assert result2["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configuration" + assert result["errors"] == {} with ( patch( @@ -127,7 +127,7 @@ async def test_user_flow_with_no_2fa(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_DEVICE_NAME: DEVICE_NAME, @@ -135,9 +135,9 @@ async def test_user_flow_with_no_2fa(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == USERNAME - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"] == { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, "tokens": { @@ -177,7 +177,7 @@ async def test_user_flow_2fa(hass: HomeAssistant) -> None: "ChallengeName": "SMS_MFA", }, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_USERNAME: USERNAME, @@ -185,9 +185,9 @@ async def test_user_flow_2fa(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == CONF_CODE - assert result2["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE + assert result["errors"] == {} with patch( "homeassistant.components.hive.config_flow.Auth.sms_2fa", @@ -199,16 +199,16 @@ async def test_user_flow_2fa(hass: HomeAssistant) -> None: }, }, ): - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CODE: MFA_CODE, }, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "configuration" - assert result3["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configuration" + assert result["errors"] == {} with ( patch( @@ -228,7 +228,7 @@ async def test_user_flow_2fa(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_DEVICE_NAME: DEVICE_NAME, @@ -236,9 +236,9 @@ async def test_user_flow_2fa(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == USERNAME - assert result4["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"] == { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, "tokens": { @@ -295,7 +295,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: }, }, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_USERNAME: USERNAME, @@ -306,36 +306,102 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert mock_config.data.get("username") == USERNAME assert mock_config.data.get("password") == UPDATED_PASSWORD - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 async def test_reauth_2fa_flow(hass: HomeAssistant) -> None: - """Test the reauth flow.""" + """Test the reauth 2FA flow when the device is still registered.""" mock_config = MockConfigEntry( domain=DOMAIN, unique_id=USERNAME, data={ CONF_USERNAME: USERNAME, - CONF_PASSWORD: INCORRECT_PASSWORD, + CONF_PASSWORD: PASSWORD, "tokens": { "AccessToken": "mock-access-token", "RefreshToken": "mock-refresh-token", }, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], }, ) mock_config.add_to_hass(hass) with patch( "homeassistant.components.hive.config_flow.Auth.login", - side_effect=hive_exceptions.HiveInvalidPassword(), + return_value={ + "ChallengeName": "SMS_MFA", + }, ): result = await mock_config.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_password"} + assert result["step_id"] == CONF_CODE + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.hive.config_flow.Auth.sms_2fa", + return_value={ + "ChallengeName": "SUCCESS", + "AuthenticationResult": { + "RefreshToken": "mock-refresh-token", + "AccessToken": "mock-access-token", + }, + }, + ), + patch( + "homeassistant.components.hive.config_flow.Auth.is_device_registered", + return_value=True, + ), + patch( + "homeassistant.components.hive.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CODE: MFA_CODE, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get("username") == USERNAME + assert mock_config.data.get("password") == PASSWORD + assert mock_config.data.get("device_data") == [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ] + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + mock_setup_entry.assert_called_once() + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_2fa_flow_device_not_registered(hass: HomeAssistant) -> None: + """Test the reauth 2FA flow when the device has been deleted from the Hive app.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=USERNAME, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + "tokens": { + "AccessToken": "mock-access-token", + "RefreshToken": "mock-refresh-token", + }, + }, + ) + mock_config.add_to_hass(hass) with patch( "homeassistant.components.hive.config_flow.Auth.login", @@ -343,14 +409,136 @@ async def test_reauth_2fa_flow(hass: HomeAssistant) -> None: "ChallengeName": "SMS_MFA", }, ): - result2 = await hass.config_entries.flow.async_configure( + result = await mock_config.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.hive.config_flow.Auth.sms_2fa", + return_value={ + "ChallengeName": "SUCCESS", + "AuthenticationResult": { + "RefreshToken": "mock-refresh-token", + "AccessToken": "mock-access-token", + }, + }, + ), + patch( + "homeassistant.components.hive.config_flow.Auth.is_device_registered", + return_value=False, + ), + ): + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: UPDATED_PASSWORD, + CONF_CODE: MFA_CODE, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configuration" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.hive.config_flow.Auth.device_registration", + return_value=True, + ), + patch( + "homeassistant.components.hive.config_flow.Auth.get_device_data", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], + ), + patch( + "homeassistant.components.hive.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_NAME: DEVICE_NAME, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get("username") == USERNAME + assert mock_config.data.get("password") == PASSWORD + assert mock_config.data.get("device_data") == [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ] + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_2fa_flow_device_registration_check_fails( + hass: HomeAssistant, +) -> None: + """Test the reauth 2FA flow when is_device_registered() raises HiveApiError.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=USERNAME, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + "tokens": { + "AccessToken": "mock-access-token", + "RefreshToken": "mock-refresh-token", + }, + }, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SMS_MFA", + }, + ): + result = await mock_config.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.hive.config_flow.Auth.sms_2fa", + return_value={ + "ChallengeName": "SUCCESS", + "AuthenticationResult": { + "RefreshToken": "mock-refresh-token", + "AccessToken": "mock-access-token", + }, + }, + ), + patch( + "homeassistant.components.hive.config_flow.Auth.is_device_registered", + side_effect=hive_exceptions.HiveApiError(), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CODE: MFA_CODE, }, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE + assert result["errors"] == {"base": "no_internet_available"} + with ( patch( "homeassistant.components.hive.config_flow.Auth.sms_2fa", @@ -362,13 +550,17 @@ async def test_reauth_2fa_flow(hass: HomeAssistant) -> None: }, }, ), + patch( + "homeassistant.components.hive.config_flow.Auth.is_device_registered", + return_value=True, + ), patch( "homeassistant.components.hive.async_setup_entry", return_value=True, ) as mock_setup_entry, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_CODE: MFA_CODE, }, @@ -376,10 +568,10 @@ async def test_reauth_2fa_flow(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert mock_config.data.get("username") == USERNAME - assert mock_config.data.get("password") == UPDATED_PASSWORD - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reauth_successful" - assert len(mock_setup_entry.mock_calls) == 1 + assert mock_config.data.get("password") == PASSWORD + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + mock_setup_entry.assert_called_once() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -435,7 +627,7 @@ async def test_user_flow_2fa_send_new_code(hass: HomeAssistant) -> None: "ChallengeName": "SMS_MFA", }, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_USERNAME: USERNAME, @@ -443,9 +635,9 @@ async def test_user_flow_2fa_send_new_code(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == CONF_CODE - assert result2["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE + assert result["errors"] == {} with patch( "homeassistant.components.hive.config_flow.Auth.login", @@ -453,14 +645,14 @@ async def test_user_flow_2fa_send_new_code(hass: HomeAssistant) -> None: "ChallengeName": "SMS_MFA", }, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {CONF_CODE: MFA_RESEND_CODE} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CODE: MFA_RESEND_CODE} ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == CONF_CODE - assert result3["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE + assert result["errors"] == {} with patch( "homeassistant.components.hive.config_flow.Auth.sms_2fa", @@ -472,16 +664,16 @@ async def test_user_flow_2fa_send_new_code(hass: HomeAssistant) -> None: }, }, ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CODE: MFA_CODE, }, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "configuration" - assert result4["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configuration" + assert result["errors"] == {} with ( patch( @@ -501,14 +693,14 @@ async def test_user_flow_2fa_send_new_code(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], {CONF_DEVICE_NAME: DEVICE_NAME} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DEVICE_NAME: DEVICE_NAME} ) await hass.async_block_till_done() - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["title"] == USERNAME - assert result5["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"] == { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, "tokens": { @@ -564,14 +756,14 @@ async def test_user_flow_invalid_username(hass: HomeAssistant) -> None: "homeassistant.components.hive.config_flow.Auth.login", side_effect=hive_exceptions.HiveInvalidUsername(), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_username"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_username"} async def test_user_flow_invalid_password(hass: HomeAssistant) -> None: @@ -587,14 +779,14 @@ async def test_user_flow_invalid_password(hass: HomeAssistant) -> None: "homeassistant.components.hive.config_flow.Auth.login", side_effect=hive_exceptions.HiveInvalidPassword(), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_password"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_password"} async def test_user_flow_no_internet_connection(hass: HomeAssistant) -> None: @@ -611,14 +803,14 @@ async def test_user_flow_no_internet_connection(hass: HomeAssistant) -> None: "homeassistant.components.hive.config_flow.Auth.login", side_effect=hive_exceptions.HiveApiError(), ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "no_internet_available"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_internet_available"} async def test_user_flow_2fa_no_internet_connection(hass: HomeAssistant) -> None: @@ -637,27 +829,27 @@ async def test_user_flow_2fa_no_internet_connection(hass: HomeAssistant) -> None "ChallengeName": "SMS_MFA", }, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == CONF_CODE - assert result2["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE + assert result["errors"] == {} with patch( "homeassistant.components.hive.config_flow.Auth.sms_2fa", side_effect=hive_exceptions.HiveApiError(), ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CODE: MFA_CODE}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == CONF_CODE - assert result3["errors"] == {"base": "no_internet_available"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE + assert result["errors"] == {"base": "no_internet_available"} async def test_user_flow_2fa_invalid_code(hass: HomeAssistant) -> None: @@ -675,26 +867,26 @@ async def test_user_flow_2fa_invalid_code(hass: HomeAssistant) -> None: "ChallengeName": "SMS_MFA", }, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == CONF_CODE - assert result2["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE + assert result["errors"] == {} with patch( "homeassistant.components.hive.config_flow.Auth.sms_2fa", side_effect=hive_exceptions.HiveInvalid2FACode(), ): - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_CODE: MFA_INVALID_CODE}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == CONF_CODE - assert result3["errors"] == {"base": "invalid_code"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE + assert result["errors"] == {"base": "invalid_code"} async def test_user_flow_unknown_error(hass: HomeAssistant) -> None: @@ -710,14 +902,14 @@ async def test_user_flow_unknown_error(hass: HomeAssistant) -> None: "homeassistant.components.hive.config_flow.Auth.login", return_value={"ChallengeName": "FAILED", "InvalidAuthenticationResult": {}}, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} async def test_user_flow_2fa_unknown_error(hass: HomeAssistant) -> None: @@ -735,26 +927,26 @@ async def test_user_flow_2fa_unknown_error(hass: HomeAssistant) -> None: "ChallengeName": "SMS_MFA", }, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == CONF_CODE + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == CONF_CODE with patch( "homeassistant.components.hive.config_flow.Auth.sms_2fa", return_value={"ChallengeName": "FAILED", "InvalidAuthenticationResult": {}}, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CODE: MFA_CODE}, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "configuration" - assert result3["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configuration" + assert result["errors"] == {} with ( patch( @@ -770,12 +962,12 @@ async def test_user_flow_2fa_unknown_error(hass: HomeAssistant) -> None: ], ), ): - result4 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DEVICE_NAME: DEVICE_NAME}, ) await hass.async_block_till_done() - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "configuration" - assert result4["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configuration" + assert result["errors"] == {"base": "unknown"} diff --git a/tests/components/mobile_app/test_device_tracker.py b/tests/components/mobile_app/test_device_tracker.py index 8f56710f1872d..162beaa2c6c2e 100644 --- a/tests/components/mobile_app/test_device_tracker.py +++ b/tests/components/mobile_app/test_device_tracker.py @@ -8,9 +8,16 @@ from homeassistant.components import zone from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component +from tests.common import ( + async_mock_restore_state_shutdown_restart, + mock_restore_cache, + mock_restore_cache_with_extra_data, +) + @pytest.fixture async def setup_zone(hass: HomeAssistant) -> None: @@ -99,6 +106,7 @@ async def setup_zone(hass: HomeAssistant) -> None: ({"location_name": "home"}, {"in_zones": []}, "home"), ({"location_name": "office"}, {"in_zones": []}, "Office"), ({"location_name": "school"}, {"in_zones": []}, "School"), + ({"location_name": "no_such_zone"}, {"in_zones": []}, "no_such_zone"), # Send coordinates only - location is determined by coordinates ( {"gps": [10, 20]}, @@ -267,3 +275,382 @@ async def test_restoring_location( assert state_2.attributes["course"] == 60 assert state_2.attributes["speed"] == 70 assert state_2.attributes["vertical_accuracy"] == 80 + + +@pytest.mark.usefixtures("setup_zone") +@pytest.mark.parametrize( + ( + "extra_webhook_data", + "expected_saved_state", + "expected_saved_attributes", + "expected_extra_data", + ), + [ + # Coordinates inside a zone + ( + {"gps": [10, 20]}, + "home", + { + "friendly_name": "Test 1", + "source_type": "gps", + "battery_level": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + "latitude": 10.0, + "longitude": 20.0, + "gps_accuracy": 30, + "in_zones": ["zone.home"], + }, + { + "data": { + "gps_accuracy": 30, + "battery": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + "gps": [10, 20], + } + }, + ), + # location_name only + ( + {"location_name": "office"}, + "Office", + { + "friendly_name": "Test 1", + "source_type": "gps", + "battery_level": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + "in_zones": [], + }, + { + "data": { + "gps_accuracy": 30, + "battery": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + "location_name": "office", + } + }, + ), + ], +) +async def test_saving_state( + hass: HomeAssistant, + hass_storage: dict[str, Any], + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, + extra_webhook_data: dict[str, Any], + expected_saved_state: str, + expected_saved_attributes: dict[str, Any], + expected_extra_data: dict[str, Any], +) -> None: + """Test that the entity state is correctly persisted to storage.""" + resp = await webhook_client.post( + f"/api/webhook/{create_registrations[1]['webhook_id']}", + json={ + "type": "update_location", + "data": { + "gps_accuracy": 30, + "battery": 40, + "altitude": 50, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + } + | extra_webhook_data, + }, + ) + assert resp.status == HTTPStatus.OK + await hass.async_block_till_done() + + await async_mock_restore_state_shutdown_restart(hass) + + saved = next( + item + for item in hass_storage[RESTORE_STATE_KEY]["data"] + if item["state"]["entity_id"] == "device_tracker.test_1_2" + ) + assert saved["state"]["state"] == expected_saved_state + assert saved["state"]["attributes"] == expected_saved_attributes + assert saved["extra_data"] == expected_extra_data + + +@pytest.mark.usefixtures("setup_zone") +@pytest.mark.parametrize( + ("restored_data", "expected_state", "expected_attributes"), + [ + # Coordinates inside the home zone + ( + { + "gps": [10.0, 20.0], + "gps_accuracy": 30, + "battery": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + }, + "home", + { + "friendly_name": "Test 1", + "source_type": "gps", + "latitude": 10.0, + "longitude": 20.0, + "gps_accuracy": 30, + "battery_level": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + "in_zones": ["zone.home"], + }, + ), + # Coordinates outside any zone + ( + { + "gps": [1.0, 2.0], + "gps_accuracy": 3, + "battery": 4, + }, + "not_home", + { + "friendly_name": "Test 1", + "source_type": "gps", + "latitude": 1.0, + "longitude": 2.0, + "gps_accuracy": 3, + "battery_level": 4, + "in_zones": [], + }, + ), + # Last update was a named location only (no coords) + ( + { + "location_name": "office", + "battery": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + }, + "Office", + { + "friendly_name": "Test 1", + "source_type": "gps", + "battery_level": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + "in_zones": [], + }, + ), + ], +) +async def test_restoring_state( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + restored_data: dict[str, Any], + expected_state: str, + expected_attributes: dict[str, Any], +) -> None: + """Test that the entity restores state from storage.""" + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_unload(config_entry.entry_id) + + mock_restore_cache_with_extra_data( + hass, + [(State("device_tracker.test_1_2", ""), {"data": restored_data})], + ) + + # Reload the config entry + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.test_1_2") + assert state is not None + assert state.state == expected_state + assert state.attributes == expected_attributes + + +@pytest.mark.usefixtures("setup_zone") +@pytest.mark.parametrize( + ("restored_state", "restored_attributes", "expected_state", "expected_attributes"), + [ + # Full attributes, coordinates inside the home zone + ( + "home", + { + "source_type": "gps", + "latitude": 10.0, + "longitude": 20.0, + "gps_accuracy": 30, + "battery_level": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + }, + "home", + { + "friendly_name": "Test 1", + "source_type": "gps", + "latitude": 10.0, + "longitude": 20.0, + "gps_accuracy": 30, + "battery_level": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + "in_zones": ["zone.home"], + }, + ), + # Coordinates outside any zone + ( + "not_home", + { + "source_type": "gps", + "latitude": 1.0, + "longitude": 2.0, + "gps_accuracy": 3, + "battery_level": 4, + }, + "not_home", + { + "friendly_name": "Test 1", + "source_type": "gps", + "latitude": 1.0, + "longitude": 2.0, + "gps_accuracy": 3, + "battery_level": 4, + "in_zones": [], + }, + ), + # Last update was a named location only (no coords). The location name + # is not persisted, so the entity falls back to "unknown" on restore. + ( + "Office", + { + "source_type": "gps", + "battery_level": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + "in_zones": [], + }, + "unknown", + { + "friendly_name": "Test 1", + "source_type": "gps", + "battery_level": 40, + "altitude": 50.0, + "course": 60, + "speed": 70, + "vertical_accuracy": 80, + "in_zones": [], + }, + ), + ], +) +async def test_restoring_state_legacy_fallback( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + restored_state: str, + restored_attributes: dict[str, Any], + expected_state: str, + expected_attributes: dict[str, Any], +) -> None: + """Test fallback to legacy state attributes when no extra_data is stored. + + Covers entries persisted before MobileAppDeviceTrackerExtraStoredData existed. + """ + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_unload(config_entry.entry_id) + + mock_restore_cache( + hass, + [State("device_tracker.test_1_2", restored_state, restored_attributes)], + ) + + # Reload the config entry + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.test_1_2") + assert state is not None + assert state.state == expected_state + assert state.attributes == expected_attributes + + +@pytest.mark.parametrize( + "invalid_data", + [ + # gps without the required gps_accuracy companion + {"gps": [10.0, 20.0]}, + # battery rejected by cv.positive_int + {"battery": -1}, + # gps_accuracy rejected by cv.positive_float + {"gps_accuracy": "not-a-number"}, + ], +) +async def test_restoring_state_invalid_extra_data( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + caplog: pytest.LogCaptureFixture, + invalid_data: dict[str, Any], +) -> None: + """Test that invalid extra_data is rejected without falling back to state attrs. + + Invalid extra_data signals corrupt persisted data — discard it rather than + restore from the (separately saved) state attributes, which could be stale + or inconsistent with the rejected payload. + """ + config_entry = hass.config_entries.async_entries("mobile_app")[1] + + await hass.config_entries.async_unload(config_entry.entry_id) + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "device_tracker.test_1_2", + "not_home", + { + "source_type": "gps", + "latitude": 1.0, + "longitude": 2.0, + "gps_accuracy": 3, + "battery_level": 4, + }, + ), + {"data": invalid_data}, + ) + ], + ) + + # Reload the config entry + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert "Discarding invalid restored device tracker data" in caplog.text + + state = hass.states.get("device_tracker.test_1_2") + assert state is not None + assert state.state == "unknown" + assert state.attributes == { + "friendly_name": "Test 1", + "source_type": "gps", + "in_zones": [], + } diff --git a/tests/components/nest/test_event.py b/tests/components/nest/test_event.py index 37a81c1eb004f..466393a308a6d 100644 --- a/tests/components/nest/test_event.py +++ b/tests/components/nest/test_event.py @@ -100,11 +100,11 @@ def create_event_messages( "event.front_chime", { "device_class": "doorbell", - "event_types": ["doorbell_chime"], + "event_types": ["ring"], "friendly_name": "Front Chime", }, EventType.DOORBELL_CHIME, - "doorbell_chime", + "ring", ), ( [TraitType.CAMERA_MOTION, TraitType.CAMERA_PERSON, TraitType.CAMERA_SOUND], @@ -205,7 +205,7 @@ async def test_ignore_unrelated_event( assert state.attributes == { "device_class": "doorbell", "event_type": None, - "event_types": ["doorbell_chime"], + "event_types": ["ring"], "friendly_name": "Front Chime", } @@ -249,9 +249,9 @@ async def test_event_threads( assert state.state == "2024-08-24T12:00:02.000+00:00" assert state.attributes == { "device_class": "doorbell", - "event_types": ["doorbell_chime"], + "event_types": ["ring"], "friendly_name": "Front Chime", - "event_type": "doorbell_chime", + "event_type": "ring", "nest_event_id": ENCODED_EVENT_ID, } @@ -280,9 +280,9 @@ async def test_event_threads( ) # A second event is not received assert state.attributes == { "device_class": "doorbell", - "event_types": ["doorbell_chime"], + "event_types": ["ring"], "friendly_name": "Front Chime", - "event_type": "doorbell_chime", + "event_type": "ring", "nest_event_id": ENCODED_EVENT_ID, } @@ -309,8 +309,8 @@ async def test_event_threads( assert state.state == "2024-08-24T12:00:06.000+00:00" # Third event is received assert state.attributes == { "device_class": "doorbell", - "event_types": ["doorbell_chime"], + "event_types": ["ring"], "friendly_name": "Front Chime", - "event_type": "doorbell_chime", + "event_type": "ring", "nest_event_id": ENCODED_EVENT_ID2, } diff --git a/tests/components/proxmoxve/conftest.py b/tests/components/proxmoxve/conftest.py index 43a71cd0d91ad..1decc74ac46c2 100644 --- a/tests/components/proxmoxve/conftest.py +++ b/tests/components/proxmoxve/conftest.py @@ -85,8 +85,8 @@ def mock_setup_entry() -> Generator[AsyncMock]: with patch( "homeassistant.components.proxmoxve.async_setup_entry", return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry + ) as mock_setup: + yield mock_setup @pytest.fixture @@ -104,6 +104,9 @@ def mock_proxmox_client(): mock_api.return_value = mock_instance mock_api_cf.return_value = mock_instance + mock_instance._mock_api = mock_api + mock_instance._mock_api_cf = mock_api_cf + mock_instance.access.ticket.post.return_value = load_json_object_fixture( "access_ticket.json", DOMAIN ) diff --git a/tests/components/proxmoxve/test_config_flow.py b/tests/components/proxmoxve/test_config_flow.py index 627409e7a0def..11630e43523ec 100644 --- a/tests/components/proxmoxve/test_config_flow.py +++ b/tests/components/proxmoxve/test_config_flow.py @@ -146,8 +146,8 @@ async def test_form( "connect_timeout", ), ( - ResourceException("404", "status_message", "content"), - "no_nodes_found", + ResourceException("500", "status_message", "content"), + "api_error_no_details", ), ( requests.exceptions.ConnectionError("Connection error"), @@ -160,6 +160,71 @@ async def test_form_exceptions( mock_proxmox_client: MagicMock, exception: Exception, reason: str, +) -> None: + """Test we handle all exceptions.""" + mock_proxmox_client._mock_api_cf.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_STEP, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user_auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_AUTH_STEP_PASSWORD, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + + mock_proxmox_client._mock_api_cf.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_AUTH_STEP_PASSWORD + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + AuthenticationError("Invalid credentials"), + "invalid_auth", + ), + ( + SSLError("SSL handshake failed"), + "ssl_error", + ), + ( + ConnectTimeout("Connection timed out"), + "connect_timeout", + ), + ( + ResourceException("400", "status_message", "content"), + "no_nodes_found", + ), + ( + requests.exceptions.ConnectionError("Connection error"), + "cannot_connect", + ), + ], +) +async def test_form_node_exceptions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + exception: Exception, + reason: str, ) -> None: """Test we handle all exceptions.""" mock_proxmox_client.nodes.get.side_effect = exception @@ -200,7 +265,7 @@ async def test_form_exceptions( [ ( ResourceException("404", "status_message", "content"), - "no_nodes_found", + "no_vmlxc_found", ), ( requests.exceptions.ConnectionError("Connection error"), diff --git a/tests/components/uptimerobot/common.py b/tests/components/uptimerobot/common.py index 9aba543696a59..fb29d15bbf41a 100644 --- a/tests/components/uptimerobot/common.py +++ b/tests/components/uptimerobot/common.py @@ -4,7 +4,8 @@ from typing import Any from unittest.mock import patch -from pyuptimerobot import API_PATH_MONITORS, UptimeRobotApiResponse +from pyuptimerobot import UptimeRobotApiResponse +from pyuptimerobot.const import API_PATH_MONITORS from homeassistant import config_entries from homeassistant.components.uptimerobot.const import DOMAIN diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index f2be2e2b1c395..e6b468afa26a0 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -4,11 +4,11 @@ import pytest from pyuptimerobot import ( - API_PATH_USER_ME, UptimeRobotAuthenticationException, UptimeRobotConnectionException, UptimeRobotException, ) +from pyuptimerobot.const import API_PATH_USER_ME from homeassistant import config_entries from homeassistant.components.uptimerobot.const import DOMAIN diff --git a/tests/components/uptimerobot/test_diagnostics.py b/tests/components/uptimerobot/test_diagnostics.py index 5fe7851ccb940..3d90b85411329 100644 --- a/tests/components/uptimerobot/test_diagnostics.py +++ b/tests/components/uptimerobot/test_diagnostics.py @@ -3,7 +3,8 @@ import json from unittest.mock import patch -from pyuptimerobot import API_PATH_USER_ME, UptimeRobotException +from pyuptimerobot import UptimeRobotException +from pyuptimerobot.const import API_PATH_USER_ME from homeassistant.core import HomeAssistant diff --git a/tests/components/uptimerobot/test_sensor.py b/tests/components/uptimerobot/test_sensor.py index e5d6b12596d40..20462a27a4f93 100644 --- a/tests/components/uptimerobot/test_sensor.py +++ b/tests/components/uptimerobot/test_sensor.py @@ -6,11 +6,12 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.uptimerobot.const import COORDINATOR_UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from .common import ( + MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, MOCK_UPTIMEROBOT_MONITOR, MOCK_UPTIMEROBOT_MONITOR_2, STATE_UP, @@ -19,7 +20,7 @@ setup_uptimerobot_integration, ) -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_presentation(hass: HomeAssistant) -> None: @@ -83,3 +84,22 @@ async def test_sensor_dynamic(hass: HomeAssistant) -> None: assert (entity := hass.states.get(entity_id_2)) assert entity.state == STATE_UP + + +async def test_sensor_monitor_status_missing( + hass: HomeAssistant, +) -> None: + """Test sensor becomes unknown when the monitor status is missing.""" + monitor_without_status = {**MOCK_UPTIMEROBOT_MONITOR, "status": None} + mock_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) + mock_entry.add_to_hass(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response(data=[monitor_without_status]), + ): + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UNKNOWN diff --git a/tests/components/uptimerobot/test_switch.py b/tests/components/uptimerobot/test_switch.py index 2d1cfef811308..4974fe92f9903 100644 --- a/tests/components/uptimerobot/test_switch.py +++ b/tests/components/uptimerobot/test_switch.py @@ -3,11 +3,8 @@ from unittest.mock import patch import pytest -from pyuptimerobot import ( - API_PATH_MONITOR_DETAIL, - UptimeRobotAuthenticationException, - UptimeRobotException, -) +from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException +from pyuptimerobot.const import API_PATH_MONITOR_DETAIL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.uptimerobot.const import COORDINATOR_UPDATE_INTERVAL @@ -58,7 +55,7 @@ async def test_switch_off(hass: HomeAssistant) -> None: ), ), patch( - "pyuptimerobot.UptimeRobot.async_edit_monitor", + "pyuptimerobot.UptimeRobot.async_pause_monitor", return_value=mock_uptimerobot_api_response( api_path=API_PATH_MONITOR_DETAIL, data=MOCK_UPTIMEROBOT_MONITOR_PAUSED ), @@ -90,7 +87,7 @@ async def test_switch_on(hass: HomeAssistant) -> None: return_value=mock_uptimerobot_api_response(data=[MOCK_UPTIMEROBOT_MONITOR]), ), patch( - "pyuptimerobot.UptimeRobot.async_edit_monitor", + "pyuptimerobot.UptimeRobot.async_start_monitor", return_value=mock_uptimerobot_api_response( api_path=API_PATH_MONITOR_DETAIL, data=MOCK_UPTIMEROBOT_MONITOR, @@ -122,7 +119,7 @@ async def test_authentication_error( with ( patch( - "pyuptimerobot.UptimeRobot.async_edit_monitor", + "pyuptimerobot.UptimeRobot.async_start_monitor", side_effect=UptimeRobotAuthenticationException, ), patch( @@ -148,7 +145,7 @@ async def test_action_execution_failure(hass: HomeAssistant) -> None: with ( patch( - "pyuptimerobot.UptimeRobot.async_edit_monitor", + "pyuptimerobot.UptimeRobot.async_start_monitor", side_effect=UptimeRobotException, ), pytest.raises(HomeAssistantError) as exc_info, @@ -176,7 +173,7 @@ async def test_switch_api_failure(hass: HomeAssistant) -> None: with ( patch( - "pyuptimerobot.UptimeRobot.async_edit_monitor", + "pyuptimerobot.UptimeRobot.async_pause_monitor", side_effect=UptimeRobotException, ), pytest.raises(HomeAssistantError) as exc_info, diff --git a/tests/components/volvo/test_coordinator.py b/tests/components/volvo/test_coordinator.py index 8d1a8f0a725a6..822e4fa3d2250 100644 --- a/tests/components/volvo/test_coordinator.py +++ b/tests/components/volvo/test_coordinator.py @@ -130,6 +130,33 @@ async def test_update_coordinator_all_error( assert state.state == STATE_UNAVAILABLE +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +async def test_coordinator_location_auth_exception( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test coordinator setup when location returns VolvoAuthException.""" + configure_mock( + mock_api.async_get_location, side_effect=VolvoAuthException(403, "Forbidden") + ) + assert await setup_integration() + + # Verify no reauthentication flow is started + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert not flows + + # Verify integration loads without location entity + device_tracker_states = hass.states.async_all(domain_filter="device_tracker") + assert len(device_tracker_states) == 0 + + # Verify other entities still work + sensor_id = "sensor.volvo_xc40_odometer" + state = hass.states.get(sensor_id) + assert state.state == "30000" + + def _mock_api_failure(mock_api: VolvoCarsApi) -> AsyncMock: """Mock the Volvo API so that it raises an exception for all calls."""