Compare commits

..

23 Commits

Author SHA1 Message Date
J. Nick Koston 434d5b54ae Fix ESPHome update entity stuck on for project versions with build suffix 2026-05-29 09:35:37 -05:00
epenet 85f3141776 Fix CI failure due to missing ssdp patching in braviatv (#172561) 2026-05-29 14:18:08 +02:00
Michael a175c7c4be Handle FileNotFoundError in Immich upload_file action (#172490) 2026-05-29 13:22:26 +02:00
Zach Wolf 03c83091ab Catch network errors during Roborock config entry setup (#172492)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 13:21:01 +02:00
mhuiskes accebd7f38 Remove diagnostic category and dead translation key from pac sensor (#172548) 2026-05-29 12:51:17 +02:00
epenet 9d3bb346e9 Refactor Renault to use StrEnum (#172546) 2026-05-29 11:42:04 +02:00
mhuiskes d13721980e Fix zeversolar coordinator to raise UpdateFailed on errors (#170507) 2026-05-29 11:26:27 +02:00
Franck Nijhof ac6b5a5850 Add missing ssdp dependency to BraviaTV manifest (#172536) 2026-05-29 11:17:36 +02:00
Franck Nijhof 16dfa99673 Use state-based icon for Hue grouped light (#172535) 2026-05-29 11:17:00 +02:00
Franck Nijhof f51a02bbda Fix Volvo lock crash when API field is missing from coordinator data (#172465) 2026-05-29 10:50:55 +02:00
Paul Bottein 6a51b21242 Fix Yoto OAuth flow with cloud credentials (#172544)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-29 10:30:52 +02:00
dependabot[bot] 5eb502851c Bump docker/login-action from 4.1.0 to 4.2.0 (#172531) 2026-05-29 08:54:25 +02:00
dependabot[bot] ef20418c76 Bump github/codeql-action from 4.35.5 to 4.36.0 (#172529) 2026-05-29 08:53:42 +02:00
Erwin Douna 94ca34fd0c Portainer refactor services test (#172525) 2026-05-29 08:21:09 +02:00
Franck Nijhof 8634c22a53 Guard Shelly repairs checks for uninitialized RPC devices (#172509) 2026-05-29 09:12:25 +03:00
Brett Adams 5681ba40f1 Move Teslemetry destination name from device tracker to a sensor (#172514)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 07:56:32 +02:00
Brett Adams 8a9a1c5fed Move Tesla Fleet route destination from device tracker to a sensor (#172513)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 07:55:44 +02:00
Franck Nijhof c587e101af Reduce Wyoming satellite disconnect log to debug level (#172499) 2026-05-28 19:18:14 -05:00
Franck Nijhof 6eeeac46f3 Convert Roomba hw_version to string for device registry (#172497) 2026-05-28 23:13:08 +02:00
Franck Nijhof 86542b8ad0 Increase ConfigEntryNotReady retry backoff cap from 80s to 10 minutes (#172487) 2026-05-28 22:41:54 +02:00
Franck Nijhof 7e07e7062c Add prog operating mode to Overkiz Atlantic heater HVAC mapping (#172491) 2026-05-28 22:21:53 +02:00
Franck Nijhof d7c13fee27 Fix Tado config flow crash on device activation polling (#172486) 2026-05-28 22:06:24 +02:00
Ronald van der Meer a0a44f7a25 Refactor Duco tests to use shared fixtures (#172351) 2026-05-28 22:04:25 +02:00
58 changed files with 1019 additions and 458 deletions
+3 -3
View File
@@ -344,13 +344,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -523,7 +523,7 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: "/language:python"
@@ -3,6 +3,7 @@
"name": "Sony Bravia TV",
"codeowners": ["@bieniu", "@Drafteed"],
"config_flow": true,
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/braviatv",
"integration_type": "device",
"iot_class": "local_polling",
@@ -284,6 +284,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
UpdateDeviceClass, static_info.device_class
)
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
"""Return True if latest_version is newer than installed_version.
ESPHome project versions can carry a build suffix (e.g.
2025.11.5_c51f7548) that AwesomeVersion cannot parse. Without stripping
it the base comparison raises and the entity is forced on for every
build mismatch. Drop the suffix so the versions compare cleanly and we
only report genuinely newer firmware.
"""
return super().version_is_newer(
latest_version.partition("_")[0], installed_version.partition("_")[0]
)
@property
@esphome_state_property
def installed_version(self) -> str:
+6
View File
@@ -1,6 +1,12 @@
{
"entity": {
"light": {
"hue_grouped_light": {
"default": "mdi:lightbulb-group",
"state": {
"off": "mdi:lightbulb-group-off"
}
},
"hue_light": {
"state_attributes": {
"effect": {
-1
View File
@@ -85,7 +85,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
entity_description = LightEntityDescription(
key="hue_grouped_light",
icon="mdi:lightbulb-group",
has_entity_name=True,
name=None,
)
+1 -1
View File
@@ -67,7 +67,7 @@ async def _async_upload_file(service_call: ServiceCall) -> None:
await coordinator.api.albums.async_add_assets_to_album(
target_album, [upload_result.asset_id]
)
except ImmichError as ex:
except (ImmichError, FileNotFoundError) as ex:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="upload_failed",
@@ -57,6 +57,7 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = {
OverkizCommandParam.STANDBY: HVACMode.OFF, # main command
OverkizCommandParam.AUTO: HVACMode.AUTO,
OverkizCommandParam.EXTERNAL: HVACMode.AUTO,
OverkizCommandParam.PROG: HVACMode.AUTO,
OverkizCommandParam.INTERNAL: HVACMode.AUTO, # main command
}
+13 -6
View File
@@ -73,20 +73,26 @@ async def _get_endpoint_id(
device_reg = dr.async_get(call.hass)
device_id = call.data[ATTR_DEVICE_ID]
device = device_reg.async_get(device_id)
assert device
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
coordinator = config_entry.runtime_data
endpoint_data = None
for data in coordinator.data.values():
if (
DOMAIN,
f"{config_entry.entry_id}_{data.endpoint.id}",
) in device.identifiers:
endpoint_data = data
break
return data.endpoint.id
assert endpoint_data
return endpoint_data.endpoint.id
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
async def _get_container_and_endpoint_ids(
@@ -95,6 +101,7 @@ async def _get_container_and_endpoint_ids(
"""Get config entry, endpoint ID and container ID from the container device ID."""
device_reg = dr.async_get(call.hass)
device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID])
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
+2 -2
View File
@@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .const import CONF_LOCALE, DOMAIN, PLATFORMS
from .const import DOMAIN, PLATFORMS, RenaultConfigurationKeys
from .renault_hub import RenaultHub
from .services import async_setup_services
@@ -28,7 +28,7 @@ async def async_setup_entry(
hass: HomeAssistant, config_entry: RenaultConfigEntry
) -> bool:
"""Load a config entry."""
renault_hub = RenaultHub(hass, config_entry.data[CONF_LOCALE])
renault_hub = RenaultHub(hass, config_entry.data[RenaultConfigurationKeys.LOCALE])
try:
await renault_hub.async_initialise(config_entry)
except NotAuthenticatedException as exc:
+38 -18
View File
@@ -14,21 +14,22 @@ from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, CONF_LOGIN_TOKEN, DOMAIN
from .const import DOMAIN, RenaultConfigurationKeys
from .renault_hub import RenaultHub
_LOGGER = logging.getLogger(__name__)
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(RenaultConfigurationKeys.LOCALE.value): vol.In(
AVAILABLE_LOCALES.keys()
),
vol.Required(RenaultConfigurationKeys.USERNAME.value): str,
vol.Required(RenaultConfigurationKeys.PASSWORD.value): str,
}
)
REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
REAUTH_SCHEMA = vol.Schema({vol.Required(RenaultConfigurationKeys.PASSWORD.value): str})
class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -50,13 +51,14 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
suggested_values: Mapping[str, Any] | None = None
if user_input:
locale = user_input[CONF_LOCALE]
locale = user_input[RenaultConfigurationKeys.LOCALE]
self.renault_config.update(user_input)
self.renault_config.update(AVAILABLE_LOCALES[locale])
self.renault_hub = RenaultHub(self.hass, locale)
try:
login_success = await self.renault_hub.attempt_login(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
user_input[RenaultConfigurationKeys.USERNAME],
user_input[RenaultConfigurationKeys.PASSWORD],
)
except aiohttp.ClientConnectionError, GigyaException:
errors["base"] = "cannot_connect"
@@ -67,7 +69,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
if login_success:
if TYPE_CHECKING:
assert self.renault_hub.login_token
self.renault_config[CONF_LOGIN_TOKEN] = self.renault_hub.login_token
self.renault_config[RenaultConfigurationKeys.LOGIN_TOKEN] = (
self.renault_hub.login_token
)
return await self.async_step_kamereon()
errors["base"] = "invalid_credentials"
suggested_values = user_input
@@ -87,7 +91,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Select Kamereon account."""
if user_input:
await self.async_set_unique_id(user_input[CONF_KAMEREON_ACCOUNT_ID])
await self.async_set_unique_id(
user_input[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID]
)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
self.renault_config.update(user_input)
@@ -100,7 +106,8 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
self.renault_config.update(user_input)
return self.async_create_entry(
title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config
title=user_input[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID],
data=self.renault_config,
)
accounts = await self.renault_hub.get_account_ids()
@@ -108,13 +115,17 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="kamereon_no_account")
if len(accounts) == 1:
return await self.async_step_kamereon(
user_input={CONF_KAMEREON_ACCOUNT_ID: accounts[0]}
user_input={RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: accounts[0]}
)
return self.async_show_form(
step_id="kamereon",
data_schema=vol.Schema(
{vol.Required(CONF_KAMEREON_ACCOUNT_ID): vol.In(accounts)}
{
vol.Required(
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID.value
): vol.In(accounts)
}
),
)
@@ -132,17 +143,22 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
reauth_entry = self._get_reauth_entry()
if user_input:
# Check credentials
self.renault_hub = RenaultHub(self.hass, reauth_entry.data[CONF_LOCALE])
self.renault_hub = RenaultHub(
self.hass, reauth_entry.data[RenaultConfigurationKeys.LOCALE]
)
if await self.renault_hub.attempt_login(
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
reauth_entry.data[RenaultConfigurationKeys.USERNAME],
user_input[RenaultConfigurationKeys.PASSWORD],
):
if TYPE_CHECKING:
assert self.renault_hub.login_token
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_LOGIN_TOKEN: self.renault_hub.login_token,
RenaultConfigurationKeys.PASSWORD: user_input[
RenaultConfigurationKeys.PASSWORD
],
RenaultConfigurationKeys.LOGIN_TOKEN: self.renault_hub.login_token,
},
)
errors = {"base": "invalid_credentials"}
@@ -151,7 +167,11 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="reauth_confirm",
data_schema=REAUTH_SCHEMA,
errors=errors,
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
description_placeholders={
RenaultConfigurationKeys.USERNAME: reauth_entry.data[
RenaultConfigurationKeys.USERNAME
]
},
)
async def async_step_reconfigure(
+12 -3
View File
@@ -1,12 +1,21 @@
"""Constants for the Renault component."""
from enum import StrEnum
from homeassistant.const import Platform
DOMAIN = "renault"
CONF_LOCALE = "locale"
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
CONF_LOGIN_TOKEN = "login_token"
class RenaultConfigurationKeys(StrEnum):
"""Configuration keys."""
LOCALE = "locale"
KAMEREON_ACCOUNT_ID = "kamereon_account_id"
LOGIN_TOKEN = "login_token"
USERNAME = "username"
PASSWORD = "password"
# normal number of allowed calls per hour to the API
# for a single car and the 7 coordinator, it is a scan every 7mn
@@ -3,19 +3,18 @@
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from . import RenaultConfigEntry
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOGIN_TOKEN
from .const import RenaultConfigurationKeys
from .renault_vehicle import RenaultVehicleProxy
TO_REDACT = {
CONF_KAMEREON_ACCOUNT_ID,
CONF_LOGIN_TOKEN,
CONF_PASSWORD,
CONF_USERNAME,
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID,
RenaultConfigurationKeys.LOGIN_TOKEN,
RenaultConfigurationKeys.PASSWORD,
RenaultConfigurationKeys.USERNAME,
"radioCode",
"registrationNumber",
"vin",
+15 -13
View File
@@ -3,6 +3,7 @@
import asyncio
from datetime import timedelta
import logging
from time import time
from typing import TYPE_CHECKING
from renault_api.exceptions import NotAuthenticatedException
@@ -17,27 +18,22 @@ from homeassistant.const import (
ATTR_MODEL,
ATTR_MODEL_ID,
ATTR_NAME,
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
if TYPE_CHECKING:
from . import RenaultConfigEntry
from time import time
from .const import (
CONF_KAMEREON_ACCOUNT_ID,
CONF_LOGIN_TOKEN,
COOLING_UPDATES_SECONDS,
MAX_CALLS_PER_HOURS,
RenaultConfigurationKeys,
)
from .renault_vehicle import COORDINATORS, RenaultVehicleProxy
if TYPE_CHECKING:
from . import RenaultConfigEntry
LOGGER = logging.getLogger(__name__)
@@ -106,20 +102,26 @@ class RenaultHub:
async def async_initialise(self, config_entry: RenaultConfigEntry) -> None:
"""Set up proxy."""
# Reuse the stored login token, or fall back to a password login.
if login_token := config_entry.data.get(CONF_LOGIN_TOKEN):
if login_token := config_entry.data.get(RenaultConfigurationKeys.LOGIN_TOKEN):
self._client.session.set_login_token(login_token)
elif await self.attempt_login(
config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]
config_entry.data[RenaultConfigurationKeys.USERNAME],
config_entry.data[RenaultConfigurationKeys.PASSWORD],
):
# Persist the login token so the next setup can skip the password.
self._hass.config_entries.async_update_entry(
config_entry,
data={**config_entry.data, CONF_LOGIN_TOKEN: self.login_token},
data={
**config_entry.data,
RenaultConfigurationKeys.LOGIN_TOKEN: self.login_token,
},
)
else:
raise NotAuthenticatedException
account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID]
account_id: str = config_entry.data[
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID
]
self._account = await self._client.get_api_account(account_id)
vehicle_links = await _get_filtered_vehicles(self._account)
+26 -16
View File
@@ -1,12 +1,12 @@
"""Support for Renault services."""
from datetime import datetime
from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -19,24 +19,30 @@ if TYPE_CHECKING:
LOGGER = logging.getLogger(__name__)
ATTR_SCHEDULES = "schedules"
ATTR_VEHICLE = "vehicle"
ATTR_WHEN = "when"
class RenaultServiceArgument(StrEnum):
"""Service argument names."""
SCHEDULES = "schedules"
TEMPERATURE = "temperature"
VEHICLE = "vehicle"
WHEN = "when"
SERVICE_VEHICLE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_VEHICLE): cv.string,
vol.Required(RenaultServiceArgument.VEHICLE.value): cv.string,
}
)
SERVICE_AC_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
{
vol.Required(ATTR_TEMPERATURE): cv.positive_float,
vol.Optional(ATTR_WHEN): cv.datetime,
vol.Required(RenaultServiceArgument.TEMPERATURE.value): cv.positive_float,
vol.Optional(RenaultServiceArgument.WHEN.value): cv.datetime,
}
)
SERVICE_CHARGE_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
{
vol.Optional(ATTR_WHEN): cv.datetime,
vol.Optional(RenaultServiceArgument.WHEN.value): cv.datetime,
}
)
SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema(
@@ -62,7 +68,7 @@ SERVICE_CHARGE_SET_SCHEDULE_SCHEMA = vol.Schema(
)
SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
{
vol.Required(ATTR_SCHEDULES): vol.All(
vol.Required(RenaultServiceArgument.SCHEDULES.value): vol.All(
cv.ensure_list, [SERVICE_CHARGE_SET_SCHEDULE_SCHEMA]
),
}
@@ -89,7 +95,7 @@ SERVICE_AC_SET_SCHEDULE_SCHEMA = vol.Schema(
)
SERVICE_AC_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
{
vol.Required(ATTR_SCHEDULES): vol.All(
vol.Required(RenaultServiceArgument.SCHEDULES.value): vol.All(
cv.ensure_list, [SERVICE_AC_SET_SCHEDULE_SCHEMA]
),
}
@@ -107,8 +113,8 @@ async def ac_cancel(service_call: ServiceCall) -> None:
async def ac_start(service_call: ServiceCall) -> None:
"""Start A/C."""
temperature: float = service_call.data[ATTR_TEMPERATURE]
when: datetime | None = service_call.data.get(ATTR_WHEN)
temperature: float = service_call.data[RenaultServiceArgument.TEMPERATURE]
when: datetime | None = service_call.data.get(RenaultServiceArgument.WHEN)
proxy = get_vehicle_proxy(service_call)
LOGGER.debug("A/C start attempt: %s / %s", temperature, when)
@@ -118,7 +124,7 @@ async def ac_start(service_call: ServiceCall) -> None:
async def charge_start(service_call: ServiceCall) -> None:
"""Start Charging with optional delay."""
when: datetime | None = service_call.data.get(ATTR_WHEN)
when: datetime | None = service_call.data.get(RenaultServiceArgument.WHEN)
proxy = get_vehicle_proxy(service_call)
LOGGER.debug("Charge start attempt, when: %s", when)
@@ -128,7 +134,9 @@ async def charge_start(service_call: ServiceCall) -> None:
async def charge_set_schedules(service_call: ServiceCall) -> None:
"""Set charge schedules."""
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
schedules: list[dict[str, Any]] = service_call.data[
RenaultServiceArgument.SCHEDULES
]
proxy = get_vehicle_proxy(service_call)
charge_schedules = await proxy.get_charging_settings()
for schedule in schedules:
@@ -147,7 +155,9 @@ async def charge_set_schedules(service_call: ServiceCall) -> None:
async def ac_set_schedules(service_call: ServiceCall) -> None:
"""Set A/C schedules."""
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
schedules: list[dict[str, Any]] = service_call.data[
RenaultServiceArgument.SCHEDULES
]
proxy = get_vehicle_proxy(service_call)
hvac_schedules = await proxy.get_hvac_settings()
@@ -168,7 +178,7 @@ async def ac_set_schedules(service_call: ServiceCall) -> None:
def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy:
"""Get vehicle from service_call data."""
device_registry = dr.async_get(service_call.hass)
device_id = service_call.data[ATTR_VEHICLE]
device_id = service_call.data[RenaultServiceArgument.VEHICLE]
device_entry = device_registry.async_get(device_id)
if device_entry is None:
raise ServiceValidationError(
@@ -6,6 +6,7 @@ from datetime import timedelta
import logging
from typing import Any
import aiohttp
from roborock import (
RoborockException,
RoborockInvalidCredentials,
@@ -120,6 +121,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
translation_domain=DOMAIN,
translation_key="home_data_fail",
) from err
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.debug("Network error setting up Roborock: %s", err)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="network_error",
) from err
async def shutdown_roborock(_: Event | None = None) -> None:
await asyncio.gather(device_manager.close(), cache.flush())
@@ -677,6 +677,9 @@
"mqtt_unauthorized": {
"message": "Roborock MQTT servers rejected the connection due to rate limiting or invalid credentials. You may either attempt to reauthenticate or wait and reload the integration."
},
"network_error": {
"message": "Network error connecting to Roborock servers. Check your internet connection and the Roborock service status."
},
"no_coordinators": {
"message": "No devices were able to successfully setup"
},
+5 -1
View File
@@ -29,7 +29,11 @@ class IRobotEntity(Entity):
model=self.vacuum_state.get("sku"),
name=str(self.vacuum_state.get("name")),
sw_version=self.vacuum_state.get("softwareVer"),
hw_version=self.vacuum_state.get("hardwareRev"),
hw_version=(
str(hw_rev)
if (hw_rev := self.vacuum_state.get("hardwareRev")) is not None
else None
),
)
if mac_address := self.vacuum_state.get("hwPartsRev", {}).get(
@@ -132,6 +132,9 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
device = entry.runtime_data.rpc.device
if not device.initialized:
return
if (
(ws_config := device.config.get("ws"))
and ws_config["enable"]
@@ -169,6 +172,9 @@ def async_manage_open_wifi_ap_issue(
device = entry.runtime_data.rpc.device
if not device.initialized:
return
# Check if WiFi AP is enabled and is open (no password)
if (
(wifi_config := device.config.get("wifi"))
+6 -4
View File
@@ -40,6 +40,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
login_task: asyncio.Task | None = None
refresh_token: str | None = None
tado: Tado | None = None
tado_device_url: str = ""
user_code: str = ""
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@@ -69,8 +71,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Error while initiating Tado")
return self.async_abort(reason="cannot_connect")
assert self.tado is not None
tado_device_url = self.tado.device_verification_url()
user_code = URL(tado_device_url).query["user_code"]
self.tado_device_url = self.tado.device_verification_url()
self.user_code = URL(self.tado_device_url).query["user_code"]
async def _wait_for_login() -> None:
"""Wait for the user to login."""
@@ -119,8 +121,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user",
progress_action="wait_for_device",
description_placeholders={
"url": tado_device_url,
"code": user_code,
"url": self.tado_device_url,
"code": self.user_code,
},
progress_task=self.login_task,
)
@@ -2,7 +2,6 @@
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -83,11 +82,3 @@ class TeslaFleetDeviceTrackerRouteEntity(TeslaFleetDeviceTrackerEntity):
self.get("drive_state_active_route_longitude", False) is None
or self.get("drive_state_active_route_latitude", False) is None
)
@property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
location = self.get("drive_state_active_route_destination")
if location == "Home":
return STATE_HOME
return location
@@ -280,6 +280,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
),
TeslaFleetSensorEntityDescription(
key="drive_state_active_route_destination",
entity_registry_enabled_default=False,
),
)
@@ -11,7 +11,6 @@ from homeassistant.components.device_tracker import (
TrackerEntity,
TrackerEntityDescription,
)
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -31,12 +30,6 @@ class TeslemetryDeviceTrackerEntityDescription(TrackerEntityDescription):
[TeslemetryStreamVehicle, Callable[[TeslaLocation | None], None]],
Callable[[], None],
]
name_listener: (
Callable[
[TeslemetryStreamVehicle, Callable[[str | None], None]], Callable[[], None]
]
| None
) = None
streaming_firmware: str
polling_prefix: str | None = None
@@ -54,9 +47,6 @@ DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = (
value_listener=lambda vehicle, callback: vehicle.listen_DestinationLocation(
callback
),
name_listener=lambda vehicle, callback: vehicle.listen_DestinationName(
callback
),
streaming_firmware="2024.26",
),
TeslemetryDeviceTrackerEntityDescription(
@@ -126,11 +116,6 @@ class TeslemetryVehiclePollingDeviceTrackerEntity(
self._attr_longitude = self.get(
f"{self.entity_description.polling_prefix}_longitude"
)
self._attr_location_name = self.get(
f"{self.entity_description.polling_prefix}_destination"
)
if self._attr_location_name == "Home":
self._attr_location_name = STATE_HOME
self._attr_available = (
self._attr_latitude is not None and self._attr_longitude is not None
)
@@ -158,28 +143,14 @@ class TeslemetryStreamingDeviceTrackerEntity(
if (state := await self.async_get_last_state()) is not None:
self._attr_latitude = state.attributes.get("latitude")
self._attr_longitude = state.attributes.get("longitude")
self._attr_location_name = state.attributes.get("location_name")
self.async_on_remove(
self.entity_description.value_listener(
self.vehicle.stream_vehicle, self._location_callback
)
)
if self.entity_description.name_listener:
self.async_on_remove(
self.entity_description.name_listener(
self.vehicle.stream_vehicle, self._name_callback
)
)
def _location_callback(self, location: TeslaLocation | None) -> None:
"""Update the value of the entity."""
self._attr_latitude = None if location is None else location.latitude
self._attr_longitude = None if location is None else location.longitude
self.async_write_ha_state()
def _name_callback(self, name: str | None) -> None:
"""Update the value of the entity."""
self._attr_location_name = name
if self._attr_location_name == "Home":
self._attr_location_name = STATE_HOME
self.async_write_ha_state()
@@ -510,6 +510,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_destination",
polling=True,
streaming_listener=lambda vehicle, callback: vehicle.listen_DestinationName(
callback
),
entity_registry_enabled_default=False,
),
TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_traffic_minutes_delay",
polling=True,
+4
View File
@@ -75,6 +75,10 @@ class VolvoLock(VolvoEntity, LockEntity):
def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None:
"""Update the state of the entity."""
if api_field is None:
self._attr_is_locked = None
return
assert isinstance(api_field, VolvoCarsValue)
self._attr_is_locked = api_field.value == "LOCKED"
@@ -468,7 +468,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
async def on_restart(self) -> None:
"""Block until pipeline loop will be restarted."""
_LOGGER.warning(
_LOGGER.debug(
"Satellite has been disconnected. Reconnecting in %s second(s)",
_RECONNECT_SECONDS,
)
@@ -6,8 +6,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
LocalOAuth2ImplementationWithPkce,
)
from .const import YOTO_AUDIENCE, YOTO_SCOPES
AUTHORIZE_URL = "https://login.yotoplay.com/authorize"
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
@@ -16,9 +14,9 @@ async def async_get_auth_implementation(
hass: HomeAssistant,
auth_domain: str,
credential: ClientCredential,
) -> YotoOAuth2Implementation:
"""Return a Yoto OAuth2 implementation backed by the user's credential."""
return YotoOAuth2Implementation(
) -> LocalOAuth2ImplementationWithPkce:
"""Return a Yoto OAuth2 implementation with PKCE."""
return LocalOAuth2ImplementationWithPkce(
hass,
auth_domain,
credential.client_id,
@@ -26,15 +24,3 @@ async def async_get_auth_implementation(
TOKEN_URL,
credential.client_secret,
)
class YotoOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
"""Yoto OAuth2 implementation with PKCE, audience and scopes."""
@property
def extra_authorize_data(self) -> dict:
"""Append Yoto's audience and scopes to every authorize URL."""
return super().extra_authorize_data | {
"audience": YOTO_AUDIENCE,
"scope": " ".join(YOTO_SCOPES),
}
+9 -1
View File
@@ -8,7 +8,7 @@ from yoto_api import YotoError, get_account_id
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import _LOGGER, DOMAIN
from .const import _LOGGER, DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES
class YotoOAuth2FlowHandler(
@@ -23,6 +23,14 @@ class YotoOAuth2FlowHandler(
"""Return the logger used for the OAuth2 flow."""
return _LOGGER
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Append Yoto's audience and scopes to the authorize URL."""
return {
"audience": YOTO_AUDIENCE,
"scope": " ".join(YOTO_SCOPES),
}
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Identify the Yoto account from the access token."""
try:
@@ -8,7 +8,7 @@ import zeversolar
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -35,4 +35,7 @@ class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]):
async def _async_update_data(self) -> zeversolar.ZeverSolarData:
"""Fetch the latest data from the source."""
return await self.hass.async_add_executor_job(self._client.get_data)
try:
return await self.hass.async_add_executor_job(self._client.get_data)
except zeversolar.ZeverSolarError as err:
raise UpdateFailed(err) from err
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfPower
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -29,10 +29,8 @@ class ZeversolarEntityDescription(SensorEntityDescription):
SENSOR_TYPES = (
ZeversolarEntityDescription(
key="pac",
translation_key="pac",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.POWER,
value_fn=lambda data: data.pac,
),
+4 -15
View File
@@ -138,6 +138,8 @@ SAVE_DELAY = 1
DISCOVERY_COOLDOWN = 1
SETUP_RETRY_MAX_WAIT = 600 # 10 minutes
ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision"
UNIQUE_ID_COLLISION_TITLE_LIMIT = 5
@@ -824,7 +826,7 @@ class ConfigEntry[_DataT = Any]:
auth_message,
)
logger.debug("Full exception", exc_info=True)
self.async_start_reauth_if_available(hass)
self.async_start_reauth(hass)
except ConfigEntryNotReady as exc:
message = str(exc)
error_reason_translation_key = exc.translation_key
@@ -836,7 +838,7 @@ class ConfigEntry[_DataT = Any]:
error_reason_translation_key,
error_reason_translation_placeholders,
)
wait_time = 2 ** min(self._tries, 4) * 5 + (
wait_time = min(2**self._tries * 5, SETUP_RETRY_MAX_WAIT) + (
randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000
)
self._tries += 1
@@ -1290,19 +1292,6 @@ class ConfigEntry[_DataT = Any]:
eager_start=True,
)
@callback
def async_start_reauth_if_available(
self,
hass: HomeAssistant,
context: ConfigFlowContext | None = None,
data: dict[str, Any] | None = None,
) -> None:
"""Start a reauth flow only if the integration implements one."""
handler = HANDLERS.get(self.domain)
if handler is None or not hasattr(handler, "async_step_reauth"):
return
self.async_start_reauth(hass, context, data)
async def _async_init_reauth(
self,
hass: HomeAssistant,
+2 -2
View File
@@ -458,7 +458,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
raise ConfigEntryAuthFailed from err
if self.config_entry:
self.config_entry.async_start_reauth_if_available(self.hass)
self.config_entry.async_start_reauth(self.hass)
return
# Recoverable error
@@ -536,7 +536,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
raise
if self.config_entry:
self.config_entry.async_start_reauth_if_available(self.hass)
self.config_entry.async_start_reauth(self.hass)
except NotImplementedError as err:
self.last_exception = err
self.last_update_success = False
+17
View File
@@ -6,6 +6,23 @@ from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture(autouse=True)
def silent_ssdp_scanner() -> Generator[None]:
"""Start SSDP component and get Scanner, prevent actual SSDP traffic."""
with (
patch("homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners"),
patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"),
patch("homeassistant.components.ssdp.Scanner.async_scan"),
patch(
"homeassistant.components.ssdp.Server._async_start_upnp_servers",
),
patch(
"homeassistant.components.ssdp.Server._async_stop_upnp_servers",
),
):
yield
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
+32
View File
@@ -1 +1,33 @@
"""Tests for the Duco integration."""
from collections.abc import Sequence
from unittest.mock import patch
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> MockConfigEntry:
"""Set up the full Duco integration for testing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry
async def setup_platform_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
platforms: Sequence[Platform],
) -> MockConfigEntry:
"""Set up selected Duco platforms for testing."""
config_entry.add_to_hass(hass)
with patch("homeassistant.components.duco.PLATFORMS", list(platforms)):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry
+4 -110
View File
@@ -23,6 +23,8 @@ from homeassistant.components.duco.const import DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry, load_json_array_fixture
TEST_HOST = "192.168.1.100"
@@ -159,112 +161,7 @@ def mock_lan_info() -> LanInfo:
@pytest.fixture
def mock_nodes() -> list[Node]:
"""Return a list of nodes covering all supported types."""
return [
Node(
node_id=1,
general=NodeGeneralInfo(
node_type="BOX",
sub_type=1,
network_type="VIRT",
parent=0,
asso=0,
name="Living",
identify=0,
),
ventilation=NodeVentilationInfo(
state="AUTO",
time_state_remain=0,
time_state_end=0,
mode="AUTO",
flow_lvl_tgt=0,
),
sensor=NodeSensorInfo(
co2=None,
iaq_co2=None,
rh=None,
iaq_rh=None,
temp=27.9,
),
),
Node(
node_id=2,
general=NodeGeneralInfo(
node_type="UCCO2",
sub_type=0,
network_type="RF",
parent=1,
asso=1,
name="Office CO2",
identify=0,
),
ventilation=NodeVentilationInfo(
state="AUTO",
time_state_remain=0,
time_state_end=0,
mode="-",
flow_lvl_tgt=None,
),
sensor=NodeSensorInfo(
co2=405,
iaq_co2=80,
rh=None,
iaq_rh=None,
temp=19.8,
),
),
Node(
node_id=113,
general=NodeGeneralInfo(
node_type="BSRH",
sub_type=0,
network_type="RF",
parent=1,
asso=1,
name="Bathroom RH",
identify=0,
),
ventilation=NodeVentilationInfo(
state="AUTO",
time_state_remain=0,
time_state_end=0,
mode="-",
flow_lvl_tgt=None,
),
sensor=NodeSensorInfo(
co2=None,
iaq_co2=None,
rh=42.0,
iaq_rh=85,
temp=27.9,
),
),
Node(
node_id=50,
general=NodeGeneralInfo(
node_type="UCRH",
sub_type=0,
network_type="RF",
parent=1,
asso=1,
name="Kitchen RH",
identify=0,
),
ventilation=NodeVentilationInfo(
state="AUTO",
time_state_remain=0,
time_state_end=0,
mode="-",
flow_lvl_tgt=None,
),
sensor=NodeSensorInfo(
co2=None,
iaq_co2=None,
rh=61.0,
iaq_rh=90,
temp=22.5,
),
),
]
return load_nodes_fixture("nodes.json")
@pytest.fixture
@@ -327,7 +224,4 @@ async def init_integration(
mock_duco_client: AsyncMock,
) -> MockConfigEntry:
"""Set up the Duco integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
return await setup_integration(hass, mock_config_entry)
+106
View File
@@ -0,0 +1,106 @@
[
{
"node_id": 1,
"general": {
"node_type": "BOX",
"sub_type": 1,
"network_type": "VIRT",
"parent": 0,
"asso": 0,
"name": "Living",
"identify": 0
},
"ventilation": {
"state": "AUTO",
"time_state_remain": 0,
"time_state_end": 0,
"mode": "AUTO",
"flow_lvl_tgt": 0
},
"sensor": {
"co2": null,
"iaq_co2": null,
"rh": null,
"iaq_rh": null,
"temp": 27.9
}
},
{
"node_id": 2,
"general": {
"node_type": "UCCO2",
"sub_type": 0,
"network_type": "RF",
"parent": 1,
"asso": 1,
"name": "Office CO2",
"identify": 0
},
"ventilation": {
"state": "AUTO",
"time_state_remain": 0,
"time_state_end": 0,
"mode": "-",
"flow_lvl_tgt": null
},
"sensor": {
"co2": 405,
"iaq_co2": 80,
"rh": null,
"iaq_rh": null,
"temp": 19.8
}
},
{
"node_id": 113,
"general": {
"node_type": "BSRH",
"sub_type": 0,
"network_type": "RF",
"parent": 1,
"asso": 1,
"name": "Bathroom RH",
"identify": 0
},
"ventilation": {
"state": "AUTO",
"time_state_remain": 0,
"time_state_end": 0,
"mode": "-",
"flow_lvl_tgt": null
},
"sensor": {
"co2": null,
"iaq_co2": null,
"rh": 42.0,
"iaq_rh": 85,
"temp": 27.9
}
},
{
"node_id": 50,
"general": {
"node_type": "UCRH",
"sub_type": 0,
"network_type": "RF",
"parent": 1,
"asso": 1,
"name": "Kitchen RH",
"identify": 0
},
"ventilation": {
"state": "AUTO",
"time_state_remain": 0,
"time_state_end": 0,
"mode": "-",
"flow_lvl_tgt": null
},
"sensor": {
"co2": null,
"iaq_co2": null,
"rh": 61.0,
"iaq_rh": 90,
"temp": 22.5
}
}
]
+4 -6
View File
@@ -1,7 +1,7 @@
"""Tests for the Duco fan platform."""
import logging
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from duco_connectivity import DucoConnectionError, DucoError, DucoRateLimitError
from freezegun.api import FrozenDateTimeFactory
@@ -21,6 +21,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_platform_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
_FAN_ENTITY = "fan.living"
@@ -33,11 +35,7 @@ async def init_integration(
mock_duco_client: AsyncMock,
) -> MockConfigEntry:
"""Set up only the fan platform for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.duco.PLATFORMS", [Platform.FAN]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
return await setup_platform_integration(hass, mock_config_entry, [Platform.FAN])
@pytest.mark.usefixtures("init_integration")
+4 -6
View File
@@ -1,7 +1,7 @@
"""Tests for the Duco sensor platform."""
import logging
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from duco_connectivity import (
DucoConnectionError,
@@ -22,6 +22,8 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_platform_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -34,11 +36,7 @@ async def init_integration(
) -> MockConfigEntry:
"""Set up only the sensor platform for testing."""
mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.duco.PLATFORMS", [Platform.SENSOR]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
return await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR])
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
+124
View File
@@ -5,6 +5,8 @@ from typing import Any
from unittest.mock import patch
from aioesphomeapi import APIClient, UpdateCommand, UpdateInfo, UpdateState
from awesomeversion import AwesomeVersion
from awesomeversion.exceptions import AwesomeVersionCompareException
import pytest
from homeassistant.components.esphome.dashboard import async_get_dashboard
@@ -547,6 +549,128 @@ async def test_generic_device_update_entity_has_update(
)
@pytest.mark.parametrize(
("current_version", "latest_version"),
[
("2025.11.5_c51f7548", "2025.11.6_aabbccdd"),
("2025.11.5_c51f7548", "2025.11.5_aabbccdd"),
("2025.11.6_aabbccdd", "2025.11.5_c51f7548"),
],
ids=["newer_base", "same_base_new_build", "older_base"],
)
def test_awesomeversion_cannot_compare_project_versions(
current_version: str, latest_version: str
) -> None:
"""Prove AwesomeVersion raises on ESPHome project versions.
ESPHome project versions carry a build suffix (e.g. 2025.11.5_c51f7548).
AwesomeVersion cannot parse these, so the base UpdateEntity comparison would
raise and force the entity on, which is why ESPHomeUpdateEntity mirrors the
device by comparing with a plain string inequality instead.
"""
with pytest.raises(AwesomeVersionCompareException):
assert AwesomeVersion(latest_version) > current_version
@pytest.mark.parametrize(
("current_version", "latest_version", "expected_state"),
[
("2025.11.5_c51f7548", "2025.11.6_aabbccdd", STATE_ON),
("2025.11.5_c51f7548", "2025.11.5_aabbccdd", STATE_OFF),
("2025.11.6_aabbccdd", "2025.11.5_c51f7548", STATE_OFF),
("2025.11.5_c51f7548", "2025.11.5_c51f7548", STATE_OFF),
],
ids=["newer_base", "same_base_new_build", "older_base", "identical"],
)
async def test_generic_device_update_entity_project_version(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
current_version: str,
latest_version: str,
expected_state: str,
) -> None:
"""Test version comparison for ESPHome project versions.
AwesomeVersion cannot parse the build suffix, so the entity strips it and
compares the real versions: only a genuinely newer base version is offered;
a different build of the same version or an older version is not.
"""
entity_info = [
UpdateInfo(
object_id="myupdate",
key=1,
name="my update",
)
]
states = [
UpdateState(
key=1,
current_version=current_version,
latest_version=latest_version,
title="ESPHome Project",
release_summary=RELEASE_SUMMARY,
release_url=RELEASE_URL,
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == expected_state
async def test_generic_device_update_entity_clears_after_ota(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test a project version update clears once the device runs the new build."""
entity_info = [
UpdateInfo(
object_id="myupdate",
key=1,
name="my update",
)
]
states = [
UpdateState(
key=1,
current_version="2025.11.5_c51f7548",
latest_version="2025.11.6_aabbccdd",
title="ESPHome Project",
release_summary=RELEASE_SUMMARY,
release_url=RELEASE_URL,
)
]
mock_device = await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_ON
mock_device.set_state(
UpdateState(
key=1,
current_version="2025.11.6_aabbccdd",
latest_version="2025.11.6_aabbccdd",
title="ESPHome Project",
release_summary=RELEASE_SUMMARY,
release_url=RELEASE_URL,
)
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_OFF
async def test_update_entity_release_notes(
hass: HomeAssistant,
mock_client: APIClient,
+1 -1
View File
@@ -235,7 +235,7 @@ async def test_setup_oauth_reauth_error(
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
mock_async_start_reauth.assert_called_once_with(hass, None, None)
mock_async_start_reauth.assert_called_once_with(hass)
async def test_setup_oauth_transient_error(
+28 -9
View File
@@ -1,5 +1,6 @@
"""Test the Immich services."""
import re
from unittest.mock import Mock, patch
from aioimmich.exceptions import ImmichError, ImmichNotFoundError
@@ -210,8 +211,30 @@ async def test_upload_file_album_not_found(
)
@pytest.mark.parametrize(
("side_effect", "expected_err_message"),
[
(
ImmichError(
{
"message": "Boom! Upload failed",
"error": "Bad Request",
"statusCode": 400,
"correlationId": "nyzxjkno",
}
),
"Boom! Upload failed (error: 'Bad Request' code: '400' correlation_id: 'nyzxjkno')",
),
(
FileNotFoundError(2, "No such file or directory", "/media/screenshot.jpg"),
"[Errno 2] No such file or directory: '/media/screenshot.jpg'",
),
],
)
async def test_upload_file_upload_failed(
hass: HomeAssistant,
side_effect: Exception,
expected_err_message: str,
mock_immich: Mock,
mock_config_entry: MockConfigEntry,
mock_media_source: Mock,
@@ -219,16 +242,12 @@ async def test_upload_file_upload_failed(
"""Test upload_file service raising upload_failed."""
await setup_integration(hass, mock_config_entry)
mock_immich.assets.async_upload_asset.side_effect = ImmichError(
{
"message": "Boom! Upload failed",
"error": "Bad Request",
"statusCode": 400,
"correlationId": "nyzxjkno",
}
)
mock_immich.assets.async_upload_asset.side_effect = side_effect
with pytest.raises(
ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed"
ServiceValidationError,
match=re.escape(
f"Upload of file `/media/screenshot.jpg` failed ({expected_err_message})"
),
):
await hass.services.async_call(
DOMAIN,
+30 -1
View File
@@ -1,7 +1,7 @@
"""Test for Portainer services."""
from datetime import timedelta
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock
from pyportainer import (
PortainerAuthenticationError,
@@ -20,6 +20,7 @@ from homeassistant.components.portainer.services import (
ATTR_TIMEOUT,
SERVICE_PRUNE_IMAGES,
SERVICE_RECREATE_CONTAINER,
_get_endpoint_id,
)
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant
@@ -281,6 +282,34 @@ async def test_service_validation_errors(
)
mock_portainer_client.container_recreate.assert_not_called()
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
await hass.services.async_call(
DOMAIN,
SERVICE_PRUNE_IMAGES,
{ATTR_DEVICE_ID: container.id},
blocking=True,
)
mock_portainer_client.images_prune.assert_not_called()
async def test_service_prune_images_device_gone(
hass: HomeAssistant,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test _get_endpoint_id raises when the device ID no longer exists in the registry."""
await setup_integration(hass, mock_config_entry)
loaded_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert loaded_entry is not None
mock_call = MagicMock()
mock_call.hass = hass
mock_call.data = {ATTR_DEVICE_ID: "nonexistent_device_id"}
with pytest.raises(ServiceValidationError):
await _get_endpoint_id(mock_call, loaded_entry)
mock_portainer_client.images_prune.assert_not_called()
@pytest.mark.parametrize(
("exception", "message"),
+6 -11
View File
@@ -1,22 +1,17 @@
"""Constants for the Renault integration tests."""
from homeassistant.components.renault.const import (
CONF_KAMEREON_ACCOUNT_ID,
CONF_LOCALE,
CONF_LOGIN_TOKEN,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.components.renault.const import RenaultConfigurationKeys
MOCK_ACCOUNT_ID = "account_id_1"
MOCK_LOGIN_TOKEN = "sample-login-token"
# Mock config data to be used across multiple tests
MOCK_CONFIG = {
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
CONF_LOGIN_TOKEN: MOCK_LOGIN_TOKEN,
CONF_KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
CONF_LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
RenaultConfigurationKeys.LOGIN_TOKEN: MOCK_LOGIN_TOKEN,
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
RenaultConfigurationKeys.LOCALE: "fr_FR",
}
MOCK_VEHICLES = {
+98 -67
View File
@@ -10,13 +10,7 @@ from renault_api.renault_account import RenaultAccount
from renault_api.renault_session import RenaultSession
from homeassistant import config_entries
from homeassistant.components.renault.const import (
CONF_KAMEREON_ACCOUNT_ID,
CONF_LOCALE,
CONF_LOGIN_TOKEN,
DOMAIN,
)
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.components.renault.const import DOMAIN, RenaultConfigurationKeys
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import aiohttp_client
@@ -66,9 +60,9 @@ async def test_config_flow_single_account(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
},
)
@@ -77,9 +71,18 @@ async def test_config_flow_single_account(
assert result["errors"] == {"base": error}
data_schema = result["data_schema"].schema
assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR"
assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com"
assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test"
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.LOCALE)
== "fr_FR"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.USERNAME)
== "email@test.com"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.PASSWORD)
== "test"
)
renault_account = AsyncMock()
type(renault_account).account_id = PropertyMock(return_value="account_id_1")
@@ -104,19 +107,21 @@ async def test_config_flow_single_account(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "account_id_1"
assert result["data"][CONF_USERNAME] == "email@test.com"
assert result["data"][CONF_PASSWORD] == "test"
assert result["data"][CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1"
assert result["data"][CONF_LOCALE] == "fr_FR"
assert result["data"][RenaultConfigurationKeys.USERNAME] == "email@test.com"
assert result["data"][RenaultConfigurationKeys.PASSWORD] == "test"
assert result["data"][RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert (
result["data"][RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID] == "account_id_1"
)
assert result["data"][RenaultConfigurationKeys.LOCALE] == "fr_FR"
assert result["context"]["unique_id"] == "account_id_1"
assert len(mock_setup_entry.mock_calls) == 1
@@ -147,9 +152,9 @@ async def test_config_flow_no_account(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
},
)
@@ -200,9 +205,9 @@ async def test_config_flow_multiple_accounts(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
},
)
@@ -212,15 +217,17 @@ async def test_config_flow_multiple_accounts(
# Account selected
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_KAMEREON_ACCOUNT_ID: "account_id_2"},
user_input={RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: "account_id_2"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "account_id_2"
assert result["data"][CONF_USERNAME] == "email@test.com"
assert result["data"][CONF_PASSWORD] == "test"
assert result["data"][CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2"
assert result["data"][CONF_LOCALE] == "fr_FR"
assert result["data"][RenaultConfigurationKeys.USERNAME] == "email@test.com"
assert result["data"][RenaultConfigurationKeys.PASSWORD] == "test"
assert result["data"][RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert (
result["data"][RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID] == "account_id_2"
)
assert result["data"][RenaultConfigurationKeys.LOCALE] == "fr_FR"
assert result["context"]["unique_id"] == "account_id_2"
assert len(mock_setup_entry.mock_calls) == 1
@@ -264,9 +271,9 @@ async def test_config_flow_duplicate(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
},
)
@@ -285,8 +292,8 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non
assert result["type"] is FlowResultType.FORM
assert result["description_placeholders"] == {
CONF_NAME: "Mock Title",
CONF_USERNAME: "email@test.com",
"name": "Mock Title",
RenaultConfigurationKeys.USERNAME: "email@test.com",
}
assert result["errors"] == {}
@@ -297,13 +304,13 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "any"},
user_input={RenaultConfigurationKeys.PASSWORD: "any"},
)
assert result2["type"] is FlowResultType.FORM
assert result2["description_placeholders"] == {
CONF_NAME: "Mock Title",
CONF_USERNAME: "email@test.com",
"name": "Mock Title",
RenaultConfigurationKeys.USERNAME: "email@test.com",
}
assert result2["errors"] == {"base": "invalid_credentials"}
@@ -315,15 +322,15 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non
):
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "any"},
user_input={RenaultConfigurationKeys.PASSWORD: "any"},
)
assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"
assert config_entry.data[CONF_USERNAME] == "email@test.com"
assert config_entry.data[CONF_PASSWORD] == "any"
assert config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert config_entry.data[RenaultConfigurationKeys.USERNAME] == "email@test.com"
assert config_entry.data[RenaultConfigurationKeys.PASSWORD] == "any"
assert config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
async def test_reconfigure(
@@ -338,9 +345,18 @@ async def test_reconfigure(
assert not result["errors"]
data_schema = result["data_schema"].schema
assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR"
assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com"
assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test"
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.LOCALE)
== "fr_FR"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.USERNAME)
== "email@test.com"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.PASSWORD)
== "test"
)
renault_account = AsyncMock()
type(renault_account).account_id = PropertyMock(return_value="account_id_1")
@@ -365,20 +381,23 @@ async def test_reconfigure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email2@test.com",
CONF_PASSWORD: "test2",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email2@test.com",
RenaultConfigurationKeys.PASSWORD: "test2",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert config_entry.data[CONF_USERNAME] == "email2@test.com"
assert config_entry.data[CONF_PASSWORD] == "test2"
assert config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert config_entry.data[CONF_KAMEREON_ACCOUNT_ID] == "account_id_1"
assert config_entry.data[CONF_LOCALE] == "fr_FR"
assert config_entry.data[RenaultConfigurationKeys.USERNAME] == "email2@test.com"
assert config_entry.data[RenaultConfigurationKeys.PASSWORD] == "test2"
assert config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert (
config_entry.data[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID]
== "account_id_1"
)
assert config_entry.data[RenaultConfigurationKeys.LOCALE] == "fr_FR"
assert len(mock_setup_entry.mock_calls) == 1
@@ -395,9 +414,18 @@ async def test_reconfigure_mismatch(
assert not result["errors"]
data_schema = result["data_schema"].schema
assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR"
assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com"
assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test"
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.LOCALE)
== "fr_FR"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.USERNAME)
== "email@test.com"
)
assert (
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.PASSWORD)
== "test"
)
renault_account = AsyncMock()
type(renault_account).account_id = PropertyMock(return_value="account_id_other")
@@ -422,9 +450,9 @@ async def test_reconfigure_mismatch(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_LOCALE: "fr_FR",
CONF_USERNAME: "email2@test.com",
CONF_PASSWORD: "test2",
RenaultConfigurationKeys.LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email2@test.com",
RenaultConfigurationKeys.PASSWORD: "test2",
},
)
@@ -432,10 +460,13 @@ async def test_reconfigure_mismatch(
assert result["reason"] == "unique_id_mismatch"
# Unchanged values
assert config_entry.data[CONF_USERNAME] == "email@test.com"
assert config_entry.data[CONF_PASSWORD] == "test"
assert config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert config_entry.data[CONF_KAMEREON_ACCOUNT_ID] == "account_id_1"
assert config_entry.data[CONF_LOCALE] == "fr_FR"
assert config_entry.data[RenaultConfigurationKeys.USERNAME] == "email@test.com"
assert config_entry.data[RenaultConfigurationKeys.PASSWORD] == "test"
assert config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert (
config_entry.data[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID]
== "account_id_1"
)
assert config_entry.data[RenaultConfigurationKeys.LOCALE] == "fr_FR"
assert len(mock_setup_entry.mock_calls) == 0
+9 -12
View File
@@ -10,19 +10,13 @@ from renault_api.gigya.exceptions import GigyaException, InvalidCredentialsExcep
from renault_api.renault_session import RenaultSession
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.renault.const import (
CONF_KAMEREON_ACCOUNT_ID,
CONF_LOCALE,
CONF_LOGIN_TOKEN,
DOMAIN,
)
from homeassistant.components.renault.const import DOMAIN, RenaultConfigurationKeys
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_USER,
ConfigEntry,
ConfigEntryState,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
@@ -34,10 +28,10 @@ from tests.typing import WebSocketGenerator
# Config data of an entry created before the login token was stored.
MOCK_CONFIG_NO_TOKEN = {
CONF_USERNAME: "email@test.com",
CONF_PASSWORD: "test",
CONF_KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
CONF_LOCALE: "fr_FR",
RenaultConfigurationKeys.USERNAME: "email@test.com",
RenaultConfigurationKeys.PASSWORD: "test",
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
RenaultConfigurationKeys.LOCALE: "fr_FR",
}
@@ -102,7 +96,10 @@ async def test_setup_entry_password_login(
assert mock_login.called
assert legacy_config_entry.state is ConfigEntryState.LOADED
# The obtained login token is persisted so future setups skip the password.
assert legacy_config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
assert (
legacy_config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN]
== MOCK_LOGIN_TOKEN
)
async def test_setup_entry_bad_password(
+50 -38
View File
@@ -2,6 +2,7 @@
from collections.abc import Generator
from datetime import datetime
from enum import StrEnum
from unittest.mock import patch
import pytest
@@ -11,13 +12,8 @@ from renault_api.kamereon.models import ChargeSchedule, HvacSchedule
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.renault.const import DOMAIN
from homeassistant.components.renault.services import (
ATTR_SCHEDULES,
ATTR_VEHICLE,
ATTR_WHEN,
)
from homeassistant.components.renault.services import RenaultServiceArgument
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
@@ -27,6 +23,16 @@ from tests.common import async_load_fixture
pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles")
class RenaultService(StrEnum):
"""Renault service names."""
AC_CANCEL = "ac_cancel"
AC_SET_SCHEDULES = "ac_set_schedules"
AC_START = "ac_start"
CHARGE_SET_SCHEDULES = "charge_set_schedules"
CHARGE_START = "charge_start"
@pytest.fixture(autouse=True)
def override_platforms() -> Generator[None]:
"""Override PLATFORMS."""
@@ -56,7 +62,7 @@ async def test_service_set_ac_cancel(
await hass.async_block_till_done()
data = {
ATTR_VEHICLE: get_device_id(hass),
RenaultServiceArgument.VEHICLE: get_device_id(hass),
}
with patch(
@@ -68,7 +74,7 @@ async def test_service_set_ac_cancel(
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "ac_cancel", service_data=data, blocking=True
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == ()
@@ -83,8 +89,8 @@ async def test_service_set_ac_start_simple(
temperature = 13.5
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_TEMPERATURE: temperature,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.TEMPERATURE: temperature,
}
with patch(
@@ -96,7 +102,7 @@ async def test_service_set_ac_start_simple(
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "ac_start", service_data=data, blocking=True
DOMAIN, RenaultService.AC_START, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (temperature, None)
@@ -112,9 +118,9 @@ async def test_service_set_ac_start_with_date(
temperature = 13.5
when = datetime(2025, 8, 23, 17, 12, 45)
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_TEMPERATURE: temperature,
ATTR_WHEN: when,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.TEMPERATURE: temperature,
RenaultServiceArgument.WHEN: when,
}
with patch(
@@ -126,7 +132,7 @@ async def test_service_set_ac_start_with_date(
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "ac_start", service_data=data, blocking=True
DOMAIN, RenaultService.AC_START, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (temperature, when)
@@ -140,7 +146,7 @@ async def test_service_charge_start_simple(
await hass.async_block_till_done()
data = {
ATTR_VEHICLE: get_device_id(hass),
RenaultServiceArgument.VEHICLE: get_device_id(hass),
}
with patch(
@@ -152,7 +158,7 @@ async def test_service_charge_start_simple(
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "charge_start", service_data=data, blocking=True
DOMAIN, RenaultService.CHARGE_START, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (None,)
@@ -167,8 +173,8 @@ async def test_service_charge_start_with_date(
when = datetime(2025, 8, 23, 17, 12, 45)
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_WHEN: when,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.WHEN: when,
}
with patch(
@@ -180,7 +186,7 @@ async def test_service_charge_start_with_date(
),
) as mock_action:
await hass.services.async_call(
DOMAIN, "charge_start", service_data=data, blocking=True
DOMAIN, RenaultService.CHARGE_START, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == (when,)
@@ -195,8 +201,8 @@ async def test_service_set_charge_schedule(
schedules = {"id": 2}
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_SCHEDULES: schedules,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.SCHEDULES: schedules,
}
with (
@@ -219,7 +225,10 @@ async def test_service_set_charge_schedule(
) as mock_action,
):
await hass.services.async_call(
DOMAIN, "charge_set_schedules", service_data=data, blocking=True
DOMAIN,
RenaultService.CHARGE_SET_SCHEDULES,
service_data=data,
blocking=True,
)
assert len(mock_action.mock_calls) == 1
mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0]
@@ -247,8 +256,8 @@ async def test_service_set_charge_schedule_multi(
{"id": 3},
]
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_SCHEDULES: schedules,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.SCHEDULES: schedules,
}
with (
@@ -271,7 +280,10 @@ async def test_service_set_charge_schedule_multi(
) as mock_action,
):
await hass.services.async_call(
DOMAIN, "charge_set_schedules", service_data=data, blocking=True
DOMAIN,
RenaultService.CHARGE_SET_SCHEDULES,
service_data=data,
blocking=True,
)
assert len(mock_action.mock_calls) == 1
mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0]
@@ -296,8 +308,8 @@ async def test_service_set_ac_schedule(
schedules = {"id": 2}
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_SCHEDULES: schedules,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.SCHEDULES: schedules,
}
with (
@@ -319,7 +331,7 @@ async def test_service_set_ac_schedule(
) as mock_action,
):
await hass.services.async_call(
DOMAIN, "ac_set_schedules", service_data=data, blocking=True
DOMAIN, RenaultService.AC_SET_SCHEDULES, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0]
@@ -347,8 +359,8 @@ async def test_service_set_ac_schedule_multi(
{"id": 4},
]
data = {
ATTR_VEHICLE: get_device_id(hass),
ATTR_SCHEDULES: schedules,
RenaultServiceArgument.VEHICLE: get_device_id(hass),
RenaultServiceArgument.SCHEDULES: schedules,
}
with (
@@ -370,7 +382,7 @@ async def test_service_set_ac_schedule_multi(
) as mock_action,
):
await hass.services.async_call(
DOMAIN, "ac_set_schedules", service_data=data, blocking=True
DOMAIN, RenaultService.AC_SET_SCHEDULES, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
mock_call_data: list[HvacSchedule] = mock_action.mock_calls[0][1][0]
@@ -393,11 +405,11 @@ async def test_service_invalid_device_id(
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
data = {ATTR_VEHICLE: "some_random_id"}
data = {RenaultServiceArgument.VEHICLE: "some_random_id"}
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
DOMAIN, "ac_cancel", service_data=data, blocking=True
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
)
assert err.value.translation_key == "invalid_device_id"
assert err.value.translation_placeholders == {"device_id": "some_random_id"}
@@ -421,11 +433,11 @@ async def test_service_invalid_device_id2(
identifiers={(DOMAIN, "VF1AAAAA111222333")},
).id
data = {ATTR_VEHICLE: device_id}
data = {RenaultServiceArgument.VEHICLE: device_id}
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
DOMAIN, "ac_cancel", service_data=data, blocking=True
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
)
assert err.value.translation_key == "no_config_entry_for_device"
assert err.value.translation_placeholders == {"device_id": "REG-NUMBER"}
@@ -439,7 +451,7 @@ async def test_service_exception(
await hass.async_block_till_done()
data = {
ATTR_VEHICLE: get_device_id(hass),
RenaultServiceArgument.VEHICLE: get_device_id(hass),
}
with (
@@ -450,7 +462,7 @@ async def test_service_exception(
pytest.raises(HomeAssistantError, match="Didn't work"),
):
await hass.services.async_call(
DOMAIN, "ac_cancel", service_data=data, blocking=True
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
)
assert len(mock_action.mock_calls) == 1
assert mock_action.mock_calls[0][1] == ()
+21
View File
@@ -5,6 +5,7 @@ import pathlib
from typing import Any
from unittest.mock import AsyncMock, patch
import aiohttp
from freezegun.api import FrozenDateTimeFactory
import pytest
from roborock import (
@@ -256,6 +257,26 @@ async def test_no_user_agreement(
assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement"
@pytest.mark.parametrize(
"side_effect",
[aiohttp.ClientError(), TimeoutError()],
ids=["client_error", "timeout"],
)
async def test_network_error_during_setup(
hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
side_effect: Exception,
) -> None:
"""Test that network errors during setup trigger retry, not terminal failure."""
with patch(
"homeassistant.components.roborock.create_device_manager",
side_effect=side_effect,
):
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY
assert mock_roborock_entry.error_reason_translation_key == "network_error"
@pytest.mark.parametrize("platforms", [[Platform.SENSOR]])
async def test_stale_device(
hass: HomeAssistant,
+17 -1
View File
@@ -4,7 +4,7 @@ from typing import Any
from unittest.mock import Mock, patch
from aioshelly.const import MODEL_PLUG, MODEL_WALL_DISPLAY
from aioshelly.exceptions import DeviceConnectionError, RpcCallError
from aioshelly.exceptions import DeviceConnectionError, NotInitialized, RpcCallError
import pytest
from homeassistant.components.shelly.const import (
@@ -179,6 +179,22 @@ async def test_outbound_websocket_incorrectly_enabled_issue(
assert len(issue_registry.issues) == 0
async def test_repairs_skipped_when_device_not_initialized(
hass: HomeAssistant,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test repair checks are skipped when the RPC device is not initialized."""
mock_rpc_device.initialized = False
type(mock_rpc_device).config = property(
lambda self: (_ for _ in ()).throw(NotInitialized)
)
await init_integration(hass, 2)
assert len(issue_registry.issues) == 0
@pytest.mark.parametrize(
"exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")]
)
+37
View File
@@ -231,6 +231,43 @@ async def test_options_flow(
assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}
async def test_show_progress_polling(
hass: HomeAssistant,
mock_tado_api: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test progress step re-entry while login task is still running."""
event = threading.Event()
def mock_tado_api_device_activation() -> None:
event.wait(timeout=5)
mock_tado_api.device_activation = mock_tado_api_device_activation
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "user"
assert result["description_placeholders"]["url"] is not None
assert result["description_placeholders"]["code"] == "TEST"
# Poll again while task is still running — this re-enters async_step_user
# with self.tado already set
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["description_placeholders"]["url"] is not None
assert result["description_placeholders"]["code"] == "TEST"
# Now complete the login
event.set()
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_homekit(hass: HomeAssistant, mock_tado_api: MagicMock) -> None:
"""Test that we abort from homekit if tado is already setup."""
@@ -108,6 +108,6 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'home',
'state': 'not_home',
})
# ---
@@ -3216,6 +3216,69 @@
'state': 'stopped',
})
# ---
# name: test_sensors[sensor.test_destination-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_destination',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Destination',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Destination',
'platform': 'tesla_fleet',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'drive_state_active_route_destination',
'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_destination',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.test_destination-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Destination',
}),
'context': <ANY>,
'entity_id': 'sensor.test_destination',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Home',
})
# ---
# name: test_sensors[sensor.test_destination-statealt]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Destination',
}),
'context': <ANY>,
'entity_id': 'sensor.test_destination',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.test_distance_to_arrival-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -108,7 +108,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'home',
'state': 'not_home',
})
# ---
# name: test_device_tracker_alt[device_tracker.test_location-statealt]
@@ -3228,6 +3228,69 @@
'state': 'stopped',
})
# ---
# name: test_sensors[sensor.test_destination-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_destination',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Destination',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Destination',
'platform': 'teslemetry',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'drive_state_active_route_destination',
'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_destination',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.test_destination-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Destination',
}),
'context': <ANY>,
'entity_id': 'sensor.test_destination',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Home',
})
# ---
# name: test_sensors[sensor.test_destination-statealt]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Destination',
}),
'context': <ANY>,
'entity_id': 'sensor.test_destination',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensors[sensor.test_distance_to_arrival-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -81,7 +81,6 @@ async def test_device_tracker_streaming(
"latitude": 3.0,
"longitude": 4.0,
},
Signal.DESTINATION_NAME: "Home",
Signal.ORIGIN_LOCATION: None,
},
"createdAt": "2024-10-04T10:45:17.537Z",
@@ -91,7 +90,7 @@ async def test_device_tracker_streaming(
# Assert the entities have correct state values
assert hass.states.get("device_tracker.test_location").state == "not_home"
assert hass.states.get("device_tracker.test_route").state == "home"
assert hass.states.get("device_tracker.test_route").state == "not_home"
assert hass.states.get("device_tracker.test_origin").state == "unknown"
# Reload the entry
+30 -2
View File
@@ -1,8 +1,10 @@
"""Test Volvo locks."""
from collections.abc import Awaitable, Callable
from datetime import timedelta
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from volvocarsapi.api import VolvoCarsApi
@@ -14,7 +16,8 @@ from homeassistant.components.lock import (
SERVICE_UNLOCK,
LockState,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.components.volvo.coordinator import FAST_INTERVAL
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
@@ -22,7 +25,7 @@ from homeassistant.helpers import entity_registry as er
from . import configure_mock
from .const import DEFAULT_VIN
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("mock_api", "full_model")
@@ -134,3 +137,28 @@ async def test_unlock_failure(
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == LockState.LOCKED
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
@pytest.mark.usefixtures("full_model")
async def test_lock_unavailable_when_api_field_missing(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test lock becomes unavailable when centralLock is missing from API response."""
with patch("homeassistant.components.volvo.PLATFORMS", [Platform.LOCK]):
assert await setup_integration()
entity_id = "lock.volvo_xc40_lock"
assert hass.states.get(entity_id).state == LockState.LOCKED
# Simulate API returning doors data without centralLock
configure_mock(mock_api.async_get_doors_status, return_value={})
freezer.tick(timedelta(minutes=FAST_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
@@ -72,7 +72,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_category': None,
'entity_id': 'sensor.zeversolar_sensor_power',
'has_entity_name': True,
'hidden_by': None,
@@ -94,7 +94,7 @@
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pac',
'translation_key': None,
'unique_id': '123456778_pac',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
+26 -3
View File
@@ -1,14 +1,17 @@
"""Test the sensor classes."""
from unittest.mock import patch
from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from syrupy.assertion import SnapshotAssertion
from zeversolar.exceptions import ZeverSolarError
from homeassistant.const import Platform
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_sensors(
@@ -25,3 +28,23 @@ async def test_sensors(
await snapshot_platform(
hass, entity_registry, snapshot, init_integration.entry_id
)
async def test_sensor_update_failed(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_zeversolar_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test entities become unavailable after a failed coordinator update."""
assert hass.states.get("sensor.zeversolar_sensor_energy_today").state is not None
mock_zeversolar_client.get_data.side_effect = ZeverSolarError
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.zeversolar_sensor_energy_today").state
== STATE_UNAVAILABLE
)
+38 -30
View File
@@ -1702,6 +1702,44 @@ async def test_setup_raise_not_ready(
assert entry.reason is None
async def test_setup_not_ready_exponential_backoff(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test setup retry uses exponential backoff capped at 10 minutes."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)
attempts = 0
async def _mock_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
nonlocal attempts
attempts += 1
raise ConfigEntryNotReady
mock_integration(hass, MockModule("test", async_setup_entry=_mock_setup_entry))
mock_platform(hass, "test.config_flow", None)
await manager.async_setup(entry.entry_id)
assert attempts == 1
expected_waits = [5, 10, 20, 40, 80, 160, 320, 600, 600]
for i, wait in enumerate(expected_waits):
# Advance to just before the retry should fire
freezer.tick(wait - 1)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert attempts == i + 1, f"Retry {i + 1} fired too early"
# Advance past the retry point (+ 1s for jitter)
freezer.tick(2)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert attempts == i + 2, f"Retry {i + 1} did not fire"
assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY
async def test_setup_raise_not_ready_from_exception(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
@@ -5755,36 +5793,6 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update(
assert len(flows) == 1
async def test_setup_raise_auth_failed_without_reauth_flow(
hass: HomeAssistant,
manager: config_entries.ConfigEntries,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test ConfigEntryAuthFailed when the integration has no reauth flow."""
entry = MockConfigEntry(title="test_title", domain="test")
entry.add_to_hass(hass)
mock_setup_entry = AsyncMock(
side_effect=ConfigEntryAuthFailed("The password is no longer valid")
)
mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry))
mock_platform(hass, "test.config_flow", None)
class NoReauthFlow(config_entries.ConfigFlow):
"""Config flow without reauth support."""
VERSION = 1
with mock_config_flow("test", NoReauthFlow):
await manager.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert "could not authenticate: The password is no longer valid" in caplog.text
assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR
assert entry.reason == "The password is no longer valid"
assert len(hass.config_entries.flow.async_progress()) == 0
async def test_initialize_and_shutdown(hass: HomeAssistant) -> None:
"""Test we call the shutdown function at stop."""
manager = config_entries.ConfigEntries(hass, {})