mirror of
https://github.com/home-assistant/core.git
synced 2026-05-29 20:23:24 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 434d5b54ae | |||
| 85f3141776 | |||
| a175c7c4be | |||
| 03c83091ab | |||
| accebd7f38 | |||
| 9d3bb346e9 | |||
| d13721980e | |||
| ac6b5a5850 | |||
| 16dfa99673 | |||
| f51a02bbda | |||
| 6a51b21242 | |||
| 5eb502851c | |||
| ef20418c76 | |||
| 94ca34fd0c | |||
| 8634c22a53 | |||
| 5681ba40f1 | |||
| 8a9a1c5fed | |||
| c587e101af | |||
| 6eeeac46f3 | |||
| 86542b8ad0 | |||
| 7e07e7062c | |||
| d7c13fee27 | |||
| a0a44f7a25 |
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"light": {
|
||||
"hue_grouped_light": {
|
||||
"default": "mdi:lightbulb-group",
|
||||
"state": {
|
||||
"off": "mdi:lightbulb-group-off"
|
||||
}
|
||||
},
|
||||
"hue_light": {
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] == ()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'>,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
Reference in New Issue
Block a user