Compare commits

...

17 Commits

Author SHA1 Message Date
Erik Montnemery
ccbd681d20 Merge branch 'dev' into adjust_sensor_group_behavior 2025-11-27 11:06:14 +01:00
Paulus Schoutsen
2ec5190243 Install requirements_test_all in dev (#157392) 2025-11-27 10:30:50 +01:00
Erik Montnemery
a706db8fdb Minor polish of cover trigger tests (#157397) 2025-11-27 09:57:03 +01:00
starkillerOG
a00923c48b Bump reolink-aio to 0.16.6 (#157399) 2025-11-27 09:53:25 +01:00
Sarah Seidman
7480d59f0f Normalize input for Droplet pairing code (#157361) 2025-11-27 08:36:30 +01:00
Erik Montnemery
4c8d9ed401 Adjust type hints in sensor group (#157373) 2025-11-27 08:34:16 +01:00
Lukas
eef10c59db Pooldose bump api 0.8.0 (new) (#157381) 2025-11-27 08:33:32 +01:00
dependabot[bot]
a1a1f8dd77 Bump docker/metadata-action from 5.5.1 to 5.9.0 (#157395) 2025-11-27 07:26:58 +01:00
dependabot[bot]
c75a5c5151 Bump docker/setup-buildx-action from 3.5.0 to 3.11.1 (#157396) 2025-11-27 07:25:16 +01:00
Allen Porter
cdaaa2bd8f Update fitbit to use new asyncio client library for device list (#157308)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-27 00:23:49 -05:00
Allen Porter
bd84dac8fb Update roborock test typing (#157370) 2025-11-27 00:21:48 -05:00
Allen Porter
42cbeca5b0 Remove old roborock map storage (#157379) 2025-11-27 00:21:04 -05:00
Allen Porter
ad0a498d10 Bump python-roborock to 3.8.1 (#157376) 2025-11-26 16:12:19 -08:00
Jan Bouwhuis
973405822b Move translatable URL out of strings.json for knx integration (#155244) 2025-11-26 23:09:59 +01:00
Franck Nijhof
b883d2f519 Bump version to 2026.1.0dev0 2025-11-26 17:15:29 +00:00
Erik Montnemery
01aa70e249 Merge branch 'dev' into adjust_sensor_group_behavior 2025-09-18 10:56:53 +02:00
Erik
9f6a0c0c77 Adjust sensor group behavior 2025-09-12 14:29:19 +02:00
30 changed files with 396 additions and 240 deletions

View File

@@ -393,7 +393,7 @@ jobs:
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
@@ -407,7 +407,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@aa33708b10e362ff993539393ff100fa93ed6a27 # v3.7.1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.12"
HA_SHORT_VERSION: "2026.1"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13', '3.14']"
# 10.3 is the oldest supported version

View File

@@ -15,6 +15,11 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
def normalize_pairing_code(code: str) -> str:
"""Normalize pairing code by removing spaces and capitalizing."""
return code.replace(" ", "").upper()
class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle Droplet config flow."""
@@ -52,14 +57,13 @@ class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
# Test if we can connect before returning
session = async_get_clientsession(self.hass)
if await self._droplet_discovery.try_connect(
session, user_input[CONF_CODE]
):
code = normalize_pairing_code(user_input[CONF_CODE])
if await self._droplet_discovery.try_connect(session, code):
device_data = {
CONF_IP_ADDRESS: self._droplet_discovery.host,
CONF_PORT: self._droplet_discovery.port,
CONF_DEVICE_ID: device_id,
CONF_CODE: user_input[CONF_CODE],
CONF_CODE: code,
}
return self.async_create_entry(
@@ -90,14 +94,15 @@ class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_IP_ADDRESS], DropletConnection.DEFAULT_PORT, ""
)
session = async_get_clientsession(self.hass)
if await self._droplet_discovery.try_connect(
session, user_input[CONF_CODE]
) and (device_id := await self._droplet_discovery.get_device_id()):
code = normalize_pairing_code(user_input[CONF_CODE])
if await self._droplet_discovery.try_connect(session, code) and (
device_id := await self._droplet_discovery.get_device_id()
):
device_data = {
CONF_IP_ADDRESS: self._droplet_discovery.host,
CONF_PORT: self._droplet_discovery.port,
CONF_DEVICE_ID: device_id,
CONF_CODE: user_input[CONF_CODE],
CONF_CODE: code,
}
await self.async_set_unique_id(device_id, raise_on_progress=False)
self._abort_if_unique_id_configured(

View File

@@ -1,22 +1,30 @@
"""API for fitbit bound to Home Assistant OAuth."""
from abc import ABC, abstractmethod
from collections.abc import Callable
from collections.abc import Awaitable, Callable
import logging
from typing import Any, cast
from fitbit import Fitbit
from fitbit.exceptions import HTTPException, HTTPUnauthorized
from fitbit_web_api import ApiClient, Configuration, DevicesApi
from fitbit_web_api.exceptions import (
ApiException,
OpenApiException,
UnauthorizedException,
)
from fitbit_web_api.models.device import Device
from requests.exceptions import ConnectionError as RequestsConnectionError
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import FitbitUnitSystem
from .exceptions import FitbitApiException, FitbitAuthException
from .model import FitbitDevice, FitbitProfile
from .model import FitbitProfile
_LOGGER = logging.getLogger(__name__)
@@ -58,6 +66,14 @@ class FitbitApi(ABC):
expires_at=float(token[CONF_EXPIRES_AT]),
)
async def _async_get_fitbit_web_api(self) -> ApiClient:
"""Create and return an ApiClient configured with the current access token."""
token = await self.async_get_access_token()
configuration = Configuration()
configuration.pool_manager = async_get_clientsession(self._hass)
configuration.access_token = token[CONF_ACCESS_TOKEN]
return ApiClient(configuration)
async def async_get_user_profile(self) -> FitbitProfile:
"""Return the user profile from the API."""
if self._profile is None:
@@ -94,21 +110,13 @@ class FitbitApi(ABC):
return FitbitUnitSystem.METRIC
return FitbitUnitSystem.EN_US
async def async_get_devices(self) -> list[FitbitDevice]:
"""Return available devices."""
client = await self._async_get_client()
devices: list[dict[str, str]] = await self._run(client.get_devices)
async def async_get_devices(self) -> list[Device]:
"""Return available devices using fitbit-web-api."""
client = await self._async_get_fitbit_web_api()
devices_api = DevicesApi(client)
devices: list[Device] = await self._run_async(devices_api.get_devices)
_LOGGER.debug("get_devices=%s", devices)
return [
FitbitDevice(
id=device["id"],
device_version=device["deviceVersion"],
battery_level=int(device["batteryLevel"]),
battery=device["battery"],
type=device["type"],
)
for device in devices
]
return devices
async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]:
"""Return the most recent value from the time series for the specified resource type."""
@@ -140,6 +148,20 @@ class FitbitApi(ABC):
_LOGGER.debug("Error from fitbit API: %s", err)
raise FitbitApiException("Error from fitbit API") from err
async def _run_async[_T](self, func: Callable[[], Awaitable[_T]]) -> _T:
"""Run client command."""
try:
return await func()
except UnauthorizedException as err:
_LOGGER.debug("Unauthorized error from fitbit API: %s", err)
raise FitbitAuthException("Authentication error from fitbit API") from err
except ApiException as err:
_LOGGER.debug("Error from fitbit API: %s", err)
raise FitbitApiException("Error from fitbit API") from err
except OpenApiException as err:
_LOGGER.debug("Error communicating with fitbit API: %s", err)
raise FitbitApiException("Communication error from fitbit API") from err
class OAuthFitbitApi(FitbitApi):
"""Provide fitbit authentication tied to an OAuth2 based config entry."""

View File

@@ -6,6 +6,8 @@ import datetime
import logging
from typing import Final
from fitbit_web_api.models.device import Device
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -13,7 +15,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .api import FitbitApi
from .exceptions import FitbitApiException, FitbitAuthException
from .model import FitbitDevice
_LOGGER = logging.getLogger(__name__)
@@ -23,7 +24,7 @@ TIMEOUT = 10
type FitbitConfigEntry = ConfigEntry[FitbitData]
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]):
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Coordinator for fetching fitbit devices from the API."""
config_entry: FitbitConfigEntry
@@ -41,7 +42,7 @@ class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]):
)
self._api = api
async def _async_update_data(self) -> dict[str, FitbitDevice]:
async def _async_update_data(self) -> dict[str, Device]:
"""Fetch data from API endpoint."""
async with asyncio.timeout(TIMEOUT):
try:
@@ -50,7 +51,7 @@ class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]):
raise ConfigEntryAuthFailed(err) from err
except FitbitApiException as err:
raise UpdateFailed(err) from err
return {device.id: device for device in devices}
return {device.id: device for device in devices if device.id is not None}
@dataclass

View File

@@ -6,6 +6,6 @@
"dependencies": ["application_credentials", "http"],
"documentation": "https://www.home-assistant.io/integrations/fitbit",
"iot_class": "cloud_polling",
"loggers": ["fitbit"],
"requirements": ["fitbit==0.3.1"]
"loggers": ["fitbit", "fitbit_web_api"],
"requirements": ["fitbit==0.3.1", "fitbit-web-api==2.13.5"]
}

View File

@@ -21,26 +21,6 @@ class FitbitProfile:
"""The locale defined in the user's Fitbit account settings."""
@dataclass
class FitbitDevice:
"""Device from the Fitbit API response."""
id: str
"""The device ID."""
device_version: str
"""The product name of the device."""
battery_level: int
"""The battery level as a percentage."""
battery: str
"""Returns the battery level of the device."""
type: str
"""The type of the device such as TRACKER or SCALE."""
@dataclass
class FitbitConfig:
"""Information from the fitbit ConfigEntry data."""

View File

@@ -8,6 +8,8 @@ import datetime
import logging
from typing import Any, Final, cast
from fitbit_web_api.models.device import Device
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -32,7 +34,7 @@ from .api import FitbitApi
from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem
from .coordinator import FitbitConfigEntry, FitbitDeviceCoordinator
from .exceptions import FitbitApiException, FitbitAuthException
from .model import FitbitDevice, config_from_entry_data
from .model import config_from_entry_data
_LOGGER: Final = logging.getLogger(__name__)
@@ -657,7 +659,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
coordinator: FitbitDeviceCoordinator,
user_profile_id: str,
description: FitbitSensorEntityDescription,
device: FitbitDevice,
device: Device,
enable_default_override: bool,
) -> None:
"""Initialize the Fitbit sensor."""
@@ -677,7 +679,9 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
@property
def icon(self) -> str | None:
"""Icon to use in the frontend, if any."""
if battery_level := BATTERY_LEVELS.get(self.device.battery):
if self.device.battery is not None and (
battery_level := BATTERY_LEVELS.get(self.device.battery)
):
return icon_for_battery_level(battery_level=battery_level)
return self.entity_description.icon
@@ -697,7 +701,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.device = self.coordinator.data[self.device.id]
self.device = self.coordinator.data[cast(str, self.device.id)]
self._attr_native_value = self.device.battery
self.async_write_ha_state()
@@ -715,7 +719,7 @@ class FitbitBatteryLevelSensor(
coordinator: FitbitDeviceCoordinator,
user_profile_id: str,
description: FitbitSensorEntityDescription,
device: FitbitDevice,
device: Device,
) -> None:
"""Initialize the Fitbit sensor."""
super().__init__(coordinator)
@@ -736,6 +740,6 @@ class FitbitBatteryLevelSensor(
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.device = self.coordinator.data[self.device.id]
self.device = self.coordinator.data[cast(str, self.device.id)]
self._attr_native_value = self.device.battery_level
self.async_write_ha_state()

View File

@@ -53,7 +53,7 @@ from homeassistant.helpers.issue_registry import (
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_IGNORE_NON_NUMERIC, DOMAIN
from .entity import GroupEntity
@@ -374,7 +374,7 @@ class SensorGroup(GroupEntity, SensorEntity):
def async_update_group_state(self) -> None:
"""Query all members and determine the sensor group state."""
self.calculate_state_attributes(self._get_valid_entities())
states: list[StateType] = []
states: list[str | None] = []
valid_units = self._valid_units
valid_states: list[bool] = []
sensor_values: list[tuple[str, float, State]] = []
@@ -435,9 +435,12 @@ class SensorGroup(GroupEntity, SensorEntity):
state.attributes.get("unit_of_measurement"),
self.entity_id,
)
else:
states.append(None)
valid_states.append(False)
# Set group as unavailable if all members do not have numeric values
self._attr_available = any(numeric_state for numeric_state in valid_states)
# Set group as unavailable if all members are unavailable or missing
self._attr_available = not all(s in (STATE_UNAVAILABLE, None) for s in states)
valid_state = self.mode(
state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states
@@ -446,6 +449,7 @@ class SensorGroup(GroupEntity, SensorEntity):
if not valid_state or not valid_state_numeric:
self._attr_native_value = None
self._extra_state_attribute = {}
return
# Calculate values

View File

@@ -39,6 +39,10 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
_DESCRIPTION_PLACEHOLDERS = {
"sensor_value_types_url": "https://www.home-assistant.io/integrations/knx/#value-types"
}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
@@ -48,6 +52,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_SEND,
service_send_to_knx_bus,
schema=SERVICE_KNX_SEND_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
hass.services.async_register(
@@ -63,6 +68,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_EVENT_REGISTER,
service_event_register_modify,
schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
async_register_admin_service(
@@ -71,6 +77,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_EXPOSURE_REGISTER,
service_exposure_register_modify,
schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
async_register_admin_service(

View File

@@ -674,7 +674,7 @@
"name": "Remove event registration"
},
"type": {
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see {sensor_value_types_url}).",
"name": "Value type"
}
},
@@ -704,7 +704,7 @@
"name": "Remove exposure"
},
"type": {
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see {sensor_value_types_url}).",
"name": "Value type"
}
},
@@ -740,7 +740,7 @@
"name": "Send as Response"
},
"type": {
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see {sensor_value_types_url}).",
"name": "Value type"
}
},

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/pooldose",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["python-pooldose==0.7.8"]
"requirements": ["python-pooldose==0.8.0"]
}

View File

@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.16.5"]
"requirements": ["reolink-aio==0.16.6"]
}

View File

@@ -33,7 +33,7 @@ from .coordinator import (
RoborockWashingMachineUpdateCoordinator,
RoborockWetDryVacUpdateCoordinator,
)
from .roborock_storage import CacheStore, async_remove_map_storage
from .roborock_storage import CacheStore, async_cleanup_map_storage
SCAN_INTERVAL = timedelta(seconds=30)
@@ -42,6 +42,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
"""Set up roborock from a config entry."""
await async_cleanup_map_storage(hass, entry.entry_id)
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
user_params = UserParams(
@@ -245,6 +246,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None:
"""Handle removal of an entry."""
await async_remove_map_storage(hass, entry.entry_id)
store = CacheStore(hass, entry.entry_id)
await store.async_remove()

View File

@@ -19,7 +19,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==3.7.1",
"python-roborock==3.8.1",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

@@ -25,8 +25,8 @@ def _storage_path_prefix(hass: HomeAssistant, entry_id: str) -> Path:
return Path(hass.config.path(STORAGE_PATH)) / entry_id
async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None:
"""Remove all map storage associated with a config entry.
async def async_cleanup_map_storage(hass: HomeAssistant, entry_id: str) -> None:
"""Remove map storage in the old format, if any.
This removes all on-disk map files for the given config entry. This is the
old format that was replaced by the `CacheStore` implementation.
@@ -34,13 +34,13 @@ async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None:
def remove(path_prefix: Path) -> None:
try:
if path_prefix.exists():
if path_prefix.exists() and path_prefix.is_dir():
_LOGGER.debug("Removing maps from disk store: %s", path_prefix)
shutil.rmtree(path_prefix, ignore_errors=True)
except OSError as err:
_LOGGER.error("Unable to remove map files in %s: %s", path_prefix, err)
path_prefix = _storage_path_prefix(hass, entry_id)
_LOGGER.debug("Removing maps from disk store: %s", path_prefix)
await hass.async_add_executor_job(remove, path_prefix)

View File

@@ -15,8 +15,8 @@ if TYPE_CHECKING:
from .helpers.typing import NoEventData
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 12
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.12.0.dev0"
version = "2026.1.0.dev0"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
@@ -830,7 +830,7 @@ ignore = [
# Disabled because ruff does not understand type of __all__ generated by a function
"PLE0605",
"FURB116"
"FURB116",
]
[tool.ruff.lint.flake8-import-conventions.extend-aliases]

9
requirements_all.txt generated
View File

@@ -958,6 +958,9 @@ fing_agent_api==1.0.3
# homeassistant.components.fints
fints==3.1.0
# homeassistant.components.fitbit
fitbit-web-api==2.13.5
# homeassistant.components.fitbit
fitbit==0.3.1
@@ -2548,7 +2551,7 @@ python-overseerr==0.7.1
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.7.8
python-pooldose==0.8.0
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2557,7 +2560,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==3.7.1
python-roborock==3.8.1
# homeassistant.components.smarttub
python-smarttub==0.0.45
@@ -2717,7 +2720,7 @@ renault-api==0.5.0
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.16.5
reolink-aio==0.16.6
# homeassistant.components.idteck_prox
rfk101py==0.0.1

View File

@@ -846,6 +846,9 @@ fing_agent_api==1.0.3
# homeassistant.components.fints
fints==3.1.0
# homeassistant.components.fitbit
fitbit-web-api==2.13.5
# homeassistant.components.fitbit
fitbit==0.3.1
@@ -2129,13 +2132,13 @@ python-overseerr==0.7.1
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.7.8
python-pooldose==0.8.0
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
# homeassistant.components.roborock
python-roborock==3.7.1
python-roborock==3.8.1
# homeassistant.components.smarttub
python-smarttub==0.0.45
@@ -2271,7 +2274,7 @@ renault-api==0.5.0
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.16.5
reolink-aio==0.16.6
# homeassistant.components.rflink
rflink==0.0.67

View File

@@ -9,9 +9,8 @@ cd "$(realpath "$(dirname "$0")/..")"
echo "Installing development dependencies..."
uv pip install \
-e . \
-r requirements_test.txt \
-r requirements_test_all.txt \
colorlog \
--constraint homeassistant/package_constraints.txt \
--upgrade \
--config-settings editable_mode=compat

View File

@@ -41,34 +41,6 @@ async def target_covers(hass: HomeAssistant) -> list[str]:
return await target_entities(hass, "cover")
@pytest.mark.parametrize(
"trigger_key",
[
"cover.awning_opened",
"cover.blind_opened",
"cover.curtain_opened",
"cover.door_opened",
"cover.garage_opened",
"cover.gate_opened",
"cover.shade_opened",
"cover.shutter_opened",
"cover.window_opened",
],
)
async def test_cover_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the cover triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
def parametrize_opened_trigger_states(
trigger: str, device_class: str
) -> list[tuple[str, dict, str, list[StateDescription]]]:
@@ -120,6 +92,33 @@ def parametrize_opened_trigger_states(
]
@pytest.mark.parametrize(
"trigger_key",
[
"cover.awning_opened",
"cover.blind_opened",
"cover.curtain_opened",
"cover.door_opened",
"cover.garage_opened",
"cover.gate_opened",
"cover.shade_opened",
"cover.shutter_opened",
"cover.window_opened",
],
)
async def test_cover_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the cover triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),

View File

@@ -23,8 +23,22 @@ from .conftest import MOCK_CODE, MOCK_DEVICE_ID, MOCK_HOST, MOCK_PORT
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("pre_normalized_code", "normalized_code"),
[
(
"abc 123",
"ABC123",
),
(" 123456 ", "123456"),
("123ABC", "123ABC"),
],
ids=["alphanumeric_lower_space", "numeric_space", "alphanumeric_no_space"],
)
async def test_user_setup(
hass: HomeAssistant,
pre_normalized_code: str,
normalized_code: str,
mock_droplet_discovery: AsyncMock,
mock_droplet_connection: AsyncMock,
mock_droplet: AsyncMock,
@@ -39,12 +53,12 @@ async def test_user_setup(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: "192.168.1.2"},
user_input={CONF_CODE: pre_normalized_code, CONF_IP_ADDRESS: "192.168.1.2"},
)
assert result is not None
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("data") == {
CONF_CODE: MOCK_CODE,
CONF_CODE: normalized_code,
CONF_DEVICE_ID: MOCK_DEVICE_ID,
CONF_IP_ADDRESS: MOCK_HOST,
CONF_PORT: MOCK_PORT,
@@ -133,8 +147,22 @@ async def test_user_setup_already_configured(
assert result.get("reason") == "already_configured"
@pytest.mark.parametrize(
("pre_normalized_code", "normalized_code"),
[
(
"abc 123",
"ABC123",
),
(" 123456 ", "123456"),
("123ABC", "123ABC"),
],
ids=["alphanumeric_lower_space", "numeric_space", "alphanumeric_no_space"],
)
async def test_zeroconf_setup(
hass: HomeAssistant,
pre_normalized_code: str,
normalized_code: str,
mock_droplet_discovery: AsyncMock,
mock_droplet: AsyncMock,
mock_droplet_connection: AsyncMock,
@@ -159,7 +187,7 @@ async def test_zeroconf_setup(
assert result.get("step_id") == "confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_CODE: MOCK_CODE}
result["flow_id"], user_input={CONF_CODE: pre_normalized_code}
)
assert result is not None
assert result.get("type") is FlowResultType.CREATE_ENTRY
@@ -167,7 +195,7 @@ async def test_zeroconf_setup(
CONF_DEVICE_ID: MOCK_DEVICE_ID,
CONF_IP_ADDRESS: MOCK_HOST,
CONF_PORT: MOCK_PORT,
CONF_CODE: MOCK_CODE,
CONF_CODE: normalized_code,
}
assert result.get("context") is not None
assert result.get("context", {}).get("unique_id") == MOCK_DEVICE_ID

View File

@@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
@@ -206,12 +207,13 @@ def mock_device_response() -> list[dict[str, Any]]:
@pytest.fixture(autouse=True)
def mock_devices(requests_mock: Mocker, devices_response: dict[str, Any]) -> None:
def mock_devices(
aioclient_mock: AiohttpClientMocker, devices_response: dict[str, Any]
) -> None:
"""Fixture to setup fake device responses."""
requests_mock.register_uri(
"GET",
aioclient_mock.get(
DEVICES_API_URL,
status_code=HTTPStatus.OK,
status=HTTPStatus.OK,
json=devices_response,
)

View File

@@ -4,7 +4,6 @@ from collections.abc import Awaitable, Callable
from http import HTTPStatus
import pytest
from requests_mock.mocker import Mocker
from homeassistant.components.fitbit.const import (
CONF_CLIENT_ID,
@@ -90,14 +89,18 @@ async def test_token_refresh_success(
assert await integration_setup()
assert config_entry.state is ConfigEntryState.LOADED
# Verify token request
assert len(aioclient_mock.mock_calls) == 1
# Verify token request and that the device API is called with new token
assert len(aioclient_mock.mock_calls) == 2
assert aioclient_mock.mock_calls[0][2] == {
CONF_CLIENT_ID: CLIENT_ID,
CONF_CLIENT_SECRET: CLIENT_SECRET,
"grant_type": "refresh_token",
"refresh_token": FAKE_REFRESH_TOKEN,
}
assert str(aioclient_mock.mock_calls[1][1]) == DEVICES_API_URL
assert aioclient_mock.mock_calls[1][3].get("Authorization") == (
"Bearer server-access-token"
)
# Verify updated token
assert (
@@ -144,15 +147,15 @@ async def test_device_update_coordinator_failure(
integration_setup: Callable[[], Awaitable[bool]],
config_entry: MockConfigEntry,
setup_credentials: None,
requests_mock: Mocker,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test case where the device update coordinator fails on the first request."""
assert config_entry.state is ConfigEntryState.NOT_LOADED
requests_mock.register_uri(
"GET",
aioclient_mock.clear_requests()
aioclient_mock.get(
DEVICES_API_URL,
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
assert not await integration_setup()
@@ -164,15 +167,15 @@ async def test_device_update_coordinator_reauth(
integration_setup: Callable[[], Awaitable[bool]],
config_entry: MockConfigEntry,
setup_credentials: None,
requests_mock: Mocker,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test case where the device update coordinator fails on the first request."""
assert config_entry.state is ConfigEntryState.NOT_LOADED
requests_mock.register_uri(
"GET",
aioclient_mock.clear_requests()
aioclient_mock.get(
DEVICES_API_URL,
status_code=HTTPStatus.UNAUTHORIZED,
status=HTTPStatus.UNAUTHORIZED,
json={
"errors": [{"errorType": "invalid_grant"}],
},

View File

@@ -29,6 +29,7 @@ from .conftest import (
)
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
DEVICE_RESPONSE_CHARGE_2 = {
"battery": "Medium",
@@ -736,31 +737,13 @@ async def test_device_battery_level_update_failed(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
requests_mock: Mocker,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test API failure for a battery level sensor for devices."""
requests_mock.register_uri(
"GET",
aioclient_mock.clear_requests()
aioclient_mock.get(
DEVICES_API_URL,
[
{
"status_code": HTTPStatus.OK,
"json": [DEVICE_RESPONSE_CHARGE_2],
},
# Fail when requesting an update
{
"status_code": HTTPStatus.INTERNAL_SERVER_ERROR,
"json": {
"errors": [
{
"errorType": "request",
"message": "An error occurred",
}
]
},
},
],
json=[DEVICE_RESPONSE_CHARGE_2],
)
assert await integration_setup()
@@ -770,6 +753,19 @@ async def test_device_battery_level_update_failed(
assert state.state == "Medium"
# Request an update for the entity which will fail
aioclient_mock.clear_requests()
aioclient_mock.get(
DEVICES_API_URL,
status=HTTPStatus.INTERNAL_SERVER_ERROR,
json={
"errors": [
{
"errorType": "request",
"message": "An error occurred",
}
]
},
)
await async_update_entity(hass, "sensor.charge_2_battery")
await hass.async_block_till_done()
@@ -791,28 +787,15 @@ async def test_device_battery_level_reauth_required(
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
config_entry: MockConfigEntry,
requests_mock: Mocker,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test API failure requires reauth."""
requests_mock.register_uri(
"GET",
aioclient_mock.clear_requests()
aioclient_mock.get(
DEVICES_API_URL,
[
{
"status_code": HTTPStatus.OK,
"json": [DEVICE_RESPONSE_CHARGE_2],
},
# Fail when requesting an update
{
"status_code": HTTPStatus.UNAUTHORIZED,
"json": {
"errors": [{"errorType": "invalid_grant"}],
},
},
],
json=[DEVICE_RESPONSE_CHARGE_2],
)
assert await integration_setup()
state = hass.states.get("sensor.charge_2_battery")
@@ -820,6 +803,14 @@ async def test_device_battery_level_reauth_required(
assert state.state == "Medium"
# Request an update for the entity which will fail
aioclient_mock.clear_requests()
aioclient_mock.get(
DEVICES_API_URL,
status=HTTPStatus.UNAUTHORIZED,
json={
"errors": [{"errorType": "invalid_grant"}],
},
)
await async_update_entity(hass, "sensor.charge_2_battery")
await hass.async_block_till_done()

View File

@@ -181,7 +181,7 @@ async def test_not_enough_sensor_value(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
state = hass.states.get("sensor.test_max")
assert state.state == STATE_UNAVAILABLE
assert state.state == STATE_UNKNOWN
assert state.attributes.get("min_entity_id") is None
assert state.attributes.get("max_entity_id") is None
@@ -203,7 +203,7 @@ async def test_not_enough_sensor_value(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
state = hass.states.get("sensor.test_max")
assert state.state == STATE_UNAVAILABLE
assert state.state == STATE_UNKNOWN
assert state.attributes.get("min_entity_id") is None
assert state.attributes.get("max_entity_id") is None
@@ -652,7 +652,7 @@ async def test_sensor_calculated_result_fails_on_uom(hass: HomeAssistant) -> Non
await hass.async_block_till_done()
state = hass.states.get("sensor.test_sum")
assert state.state == STATE_UNAVAILABLE
assert state.state == STATE_UNKNOWN
assert state.attributes.get("device_class") == "energy"
assert state.attributes.get("state_class") == "total"
assert state.attributes.get("unit_of_measurement") is None
@@ -759,6 +759,12 @@ async def test_last_sensor(hass: HomeAssistant) -> None:
entity_ids = config["sensor"]["entities"]
for entity_id in entity_ids[1:]:
hass.states.async_set(entity_id, "0.0")
await hass.async_block_till_done()
state = hass.states.get("sensor.test_last")
assert state.state == STATE_UNKNOWN
for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items():
hass.states.async_set(entity_id, value)
await hass.async_block_till_done()

View File

@@ -1,7 +1,7 @@
"""Global fixtures for Roborock integration."""
import asyncio
from collections.abc import Awaitable, Callable, Generator
from collections.abc import Generator
from copy import deepcopy
import logging
import pathlib
@@ -13,18 +13,34 @@ import pytest
from roborock import RoborockCategory
from roborock.data import (
CombinedMapInfo,
DnDTimer,
DyadError,
HomeDataDevice,
HomeDataProduct,
NamedRoomMapping,
NetworkInfo,
RoborockBase,
RoborockDyadStateCode,
ZeoError,
ZeoState,
)
from roborock.devices.device import RoborockDevice
from roborock.devices.traits.v1.map_content import MapContent
from roborock.devices.traits.v1.volume import SoundVolume
from roborock.devices.traits.v1 import PropertiesApi
from roborock.devices.traits.v1.clean_summary import CleanSummaryTrait
from roborock.devices.traits.v1.command import CommandTrait
from roborock.devices.traits.v1.common import V1TraitMixin
from roborock.devices.traits.v1.consumeable import ConsumableTrait
from roborock.devices.traits.v1.do_not_disturb import DoNotDisturbTrait
from roborock.devices.traits.v1.dust_collection_mode import DustCollectionModeTrait
from roborock.devices.traits.v1.home import HomeTrait
from roborock.devices.traits.v1.map_content import MapContent, MapContentTrait
from roborock.devices.traits.v1.maps import MapsTrait
from roborock.devices.traits.v1.network_info import NetworkInfoTrait
from roborock.devices.traits.v1.routines import RoutinesTrait
from roborock.devices.traits.v1.smart_wash_params import SmartWashParamsTrait
from roborock.devices.traits.v1.status import StatusTrait
from roborock.devices.traits.v1.volume import SoundVolumeTrait
from roborock.devices.traits.v1.wash_towel_mode import WashTowelModeTrait
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
from homeassistant.components.roborock.const import (
@@ -130,71 +146,100 @@ class FakeDeviceManager:
return self._devices
def make_fake_switch(obj: Any) -> Any:
"""Update the fake object to emulate the switch trait behavior."""
obj.is_on = True
obj.enable = AsyncMock()
obj.enable.side_effect = lambda: setattr(obj, "is_on", True)
obj.disable = AsyncMock()
obj.disable.side_effect = lambda: setattr(obj, "is_on", False)
obj.refresh = AsyncMock()
return obj
def make_mock_trait(
trait_spec: type[V1TraitMixin] | None = None,
dataclass_template: RoborockBase | None = None,
) -> AsyncMock:
"""Create a mock roborock trait."""
trait = AsyncMock(spec=trait_spec or V1TraitMixin)
if dataclass_template is not None:
# Copy all attributes and property methods (e.g. computed properties)
template_copy = deepcopy(dataclass_template)
for attr_name in dir(template_copy):
if attr_name.startswith("_"):
continue
setattr(trait, attr_name, getattr(template_copy, attr_name))
trait.refresh = AsyncMock()
return trait
def set_timer_fn(obj: Any) -> Callable[[Any], Awaitable[None]]:
def make_mock_switch(
trait_spec: type[V1TraitMixin] | None = None,
dataclass_template: RoborockBase | None = None,
) -> AsyncMock:
"""Create a mock roborock switch trait."""
trait = make_mock_trait(
trait_spec=trait_spec,
dataclass_template=dataclass_template,
)
trait.is_on = True
trait.enable = AsyncMock()
trait.enable.side_effect = lambda: setattr(trait, "is_on", True)
trait.disable = AsyncMock()
trait.disable.side_effect = lambda: setattr(trait, "is_on", False)
return trait
def make_dnd_timer(dataclass_template: RoborockBase) -> AsyncMock:
"""Make a function for the fake timer trait that emulates the real behavior."""
dnd_trait = make_mock_switch(
trait_spec=DoNotDisturbTrait,
dataclass_template=dataclass_template,
)
async def update_timer_attributes(timer: Any) -> None:
setattr(obj, "start_hour", timer.start_hour)
setattr(obj, "start_minute", timer.start_minute)
setattr(obj, "end_hour", timer.end_hour)
setattr(obj, "end_minute", timer.end_minute)
setattr(obj, "enabled", timer.enabled)
async def set_dnd_timer(timer: DnDTimer) -> None:
setattr(dnd_trait, "start_hour", timer.start_hour)
setattr(dnd_trait, "start_minute", timer.start_minute)
setattr(dnd_trait, "end_hour", timer.end_hour)
setattr(dnd_trait, "end_minute", timer.end_minute)
setattr(dnd_trait, "enabled", timer.enabled)
return update_timer_attributes
dnd_trait.set_dnd_timer = AsyncMock()
dnd_trait.set_dnd_timer.side_effect = set_dnd_timer
return dnd_trait
def create_v1_properties(network_info: NetworkInfo) -> Mock:
def create_v1_properties(network_info: NetworkInfo) -> AsyncMock:
"""Create v1 properties for each fake device."""
v1_properties = Mock()
v1_properties.status: Any = deepcopy(STATUS)
v1_properties.status.refresh = AsyncMock()
v1_properties.dnd: Any = make_fake_switch(deepcopy(DND_TIMER))
v1_properties.dnd.set_dnd_timer = AsyncMock()
v1_properties.dnd.set_dnd_timer.side_effect = set_timer_fn(v1_properties.dnd)
v1_properties.clean_summary: Any = deepcopy(CLEAN_SUMMARY)
v1_properties = AsyncMock(spec=PropertiesApi)
v1_properties.status = make_mock_trait(
trait_spec=StatusTrait,
dataclass_template=STATUS,
)
v1_properties.dnd = make_dnd_timer(dataclass_template=DND_TIMER)
v1_properties.clean_summary = make_mock_trait(
trait_spec=CleanSummaryTrait,
dataclass_template=CLEAN_SUMMARY,
)
v1_properties.clean_summary.last_clean_record = deepcopy(CLEAN_RECORD)
v1_properties.clean_summary.refresh = AsyncMock()
v1_properties.consumables = deepcopy(CONSUMABLE)
v1_properties.consumables.refresh = AsyncMock()
v1_properties.consumables = make_mock_trait(
trait_spec=ConsumableTrait, dataclass_template=CONSUMABLE
)
v1_properties.consumables.reset_consumable = AsyncMock()
v1_properties.sound_volume = SoundVolume(volume=50)
v1_properties.sound_volume = make_mock_trait(trait_spec=SoundVolumeTrait)
v1_properties.sound_volume.volume = 50
v1_properties.sound_volume.set_volume = AsyncMock()
v1_properties.sound_volume.set_volume.side_effect = lambda vol: setattr(
v1_properties.sound_volume, "volume", vol
)
v1_properties.sound_volume.refresh = AsyncMock()
v1_properties.command = AsyncMock()
v1_properties.command = AsyncMock(spec=CommandTrait)
v1_properties.command.send = AsyncMock()
v1_properties.maps = AsyncMock()
v1_properties.maps = make_mock_trait(trait_spec=MapsTrait)
v1_properties.maps.current_map = MULTI_MAP_LIST.map_info[1].map_flag
v1_properties.maps.refresh = AsyncMock()
v1_properties.maps.set_current_map = AsyncMock()
v1_properties.map_content = AsyncMock()
v1_properties.map_content = make_mock_trait(trait_spec=MapContentTrait)
v1_properties.map_content.image_content = b"\x89PNG-001"
v1_properties.map_content.map_data = deepcopy(MAP_DATA)
v1_properties.map_content.refresh = AsyncMock()
v1_properties.child_lock = make_fake_switch(AsyncMock())
v1_properties.led_status = make_fake_switch(AsyncMock())
v1_properties.flow_led_status = make_fake_switch(AsyncMock())
v1_properties.valley_electricity_timer = make_fake_switch(AsyncMock())
v1_properties.dust_collection_mode = AsyncMock()
v1_properties.dust_collection_mode.refresh = AsyncMock()
v1_properties.wash_towel_mode = AsyncMock()
v1_properties.wash_towel_mode.refresh = AsyncMock()
v1_properties.smart_wash_params = AsyncMock()
v1_properties.smart_wash_params.refresh = AsyncMock()
v1_properties.home = AsyncMock()
v1_properties.child_lock = make_mock_switch()
v1_properties.led_status = make_mock_switch()
v1_properties.flow_led_status = make_mock_switch()
v1_properties.valley_electricity_timer = make_mock_switch()
v1_properties.dust_collection_mode = make_mock_trait(
trait_spec=DustCollectionModeTrait
)
v1_properties.wash_towel_mode = make_mock_trait(trait_spec=WashTowelModeTrait)
v1_properties.smart_wash_params = make_mock_trait(trait_spec=SmartWashParamsTrait)
v1_properties.home = make_mock_trait(trait_spec=HomeTrait)
home_map_info = {
map_data.map_flag: CombinedMapInfo(
name=map_data.name,
@@ -219,10 +264,11 @@ def create_v1_properties(network_info: NetworkInfo) -> Mock:
v1_properties.home.home_map_info = home_map_info
v1_properties.home.current_map_data = home_map_info[STATUS.current_map]
v1_properties.home.home_map_content = home_map_content
v1_properties.home.refresh = AsyncMock()
v1_properties.network_info = deepcopy(network_info)
v1_properties.network_info.refresh = AsyncMock()
v1_properties.routines = AsyncMock()
v1_properties.network_info = make_mock_trait(
trait_spec=NetworkInfoTrait,
dataclass_template=network_info,
)
v1_properties.routines = make_mock_trait(trait_spec=RoutinesTrait)
v1_properties.routines.get_routines = AsyncMock(return_value=SCENES)
v1_properties.routines.execute_routine = AsyncMock()
# Mock diagnostics for a subset of properties
@@ -267,21 +313,22 @@ def fake_vacuum_fixture(fake_devices: list[FakeDevice]) -> FakeDevice:
return fake_devices[0]
@pytest.fixture(name="send_message_side_effect")
def send_message_side_effect_fixture() -> Any:
@pytest.fixture(name="send_message_exception")
def send_message_exception_fixture() -> Exception | None:
"""Fixture to return a side effect for the send_message method."""
return None
@pytest.fixture(name="vacuum_command", autouse=True)
def fake_vacuum_command_fixture(
fake_vacuum: FakeDevice, send_message_side_effect: Any
) -> Mock:
fake_vacuum: FakeDevice,
send_message_exception: Exception | None,
) -> AsyncMock:
"""Get the fake vacuum device command trait for asserting that commands happened."""
assert fake_vacuum.v1_properties is not None
command_trait = fake_vacuum.v1_properties.command
if send_message_side_effect is not None:
command_trait.send.side_effect = send_message_side_effect
if send_message_exception is not None:
command_trait.send.side_effect = send_message_exception
return command_trait

View File

@@ -52,25 +52,77 @@ async def test_reauth_started(
@pytest.mark.parametrize("platforms", [[Platform.IMAGE]])
async def test_oserror_remove_image(
@pytest.mark.parametrize(
("exists", "is_dir", "rmtree_called"),
[
(True, True, True),
(False, False, False),
(True, False, False),
],
ids=[
"old_storage_removed",
"new_storage_ignored",
"no_existing_storage",
],
)
async def test_remove_old_storage_directory(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
mock_roborock_entry: MockConfigEntry,
storage_path: pathlib.Path,
hass_client: ClientSessionGenerator,
caplog: pytest.LogCaptureFixture,
exists: bool,
is_dir: bool,
rmtree_called: bool,
) -> None:
"""Test cleanup of old old map storage."""
with (
patch(
"homeassistant.components.roborock.roborock_storage.Path.exists",
return_value=exists,
),
patch(
"homeassistant.components.roborock.roborock_storage.Path.is_dir",
return_value=is_dir,
),
patch(
"homeassistant.components.roborock.roborock_storage.shutil.rmtree",
) as mock_rmtree,
):
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
await hass.async_block_till_done()
assert mock_roborock_entry.state is ConfigEntryState.LOADED
assert mock_rmtree.called == rmtree_called
@pytest.mark.parametrize("platforms", [[Platform.IMAGE]])
async def test_oserror_remove_storage_directory(
hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
storage_path: pathlib.Path,
hass_client: ClientSessionGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that we gracefully handle failing to remove old map storage."""
with (
patch(
"homeassistant.components.roborock.roborock_storage.Path.exists",
return_value=True,
),
patch(
"homeassistant.components.roborock.roborock_storage.Path.is_dir",
return_value=True,
),
patch(
"homeassistant.components.roborock.roborock_storage.shutil.rmtree",
side_effect=OSError,
) as mock_rmtree,
):
await hass.config_entries.async_remove(setup_entry.entry_id)
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
await hass.async_block_till_done()
assert mock_roborock_entry.state is ConfigEntryState.LOADED
assert mock_rmtree.called
assert "Unable to remove map files" in caplog.text

View File

@@ -86,7 +86,7 @@ async def test_update_success(
],
)
@pytest.mark.parametrize(
"send_message_side_effect", [roborock.exceptions.RoborockTimeout]
"send_message_exception", [roborock.exceptions.RoborockTimeout]
)
async def test_update_failed(
hass: HomeAssistant,