mirror of
https://github.com/home-assistant/core.git
synced 2025-11-29 20:48:11 +00:00
Compare commits
17 Commits
2025.12.0b
...
adjust_sen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccbd681d20 | ||
|
|
2ec5190243 | ||
|
|
a706db8fdb | ||
|
|
a00923c48b | ||
|
|
7480d59f0f | ||
|
|
4c8d9ed401 | ||
|
|
eef10c59db | ||
|
|
a1a1f8dd77 | ||
|
|
c75a5c5151 | ||
|
|
cdaaa2bd8f | ||
|
|
bd84dac8fb | ||
|
|
42cbeca5b0 | ||
|
|
ad0a498d10 | ||
|
|
973405822b | ||
|
|
b883d2f519 | ||
|
|
01aa70e249 | ||
|
|
9f6a0c0c77 |
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -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'
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
9
requirements_all.txt
generated
@@ -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
|
||||
|
||||
9
requirements_test_all.txt
generated
9
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"}],
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user