Compare commits

...

26 Commits

Author SHA1 Message Date
Norbert Rittel
06870a2e25 Replace "the lock" with "a lock" in matter action descriptions (#164585) 2026-03-02 12:56:45 +01:00
willemstuursma
85eba2bb15 Bump DSMR parser to 1.5.0 (#164484) 2026-03-02 12:52:37 +01:00
Joost Lekkerkerker
5dd6dcc215 Add select for SmartThings Water spray level (#164520) 2026-03-02 12:17:31 +01:00
epenet
8bf894a514 Migrate microbees to runtime_data (#164564) 2026-03-02 12:04:34 +01:00
epenet
d3c67f2ae1 Migrate medcom_ble to runtime_data (#164557) 2026-03-02 12:03:35 +01:00
epenet
b60a282b60 Move motioneye coordinator to separate module (#164568) 2026-03-02 11:57:19 +01:00
epenet
0da1d40a19 Migrate meteoclimatic to runtime_data (#164559) 2026-03-02 11:50:46 +01:00
Robert Resch
aa3be915a0 Bump aiogithubapi to 26.0.0 (#164579) 2026-03-02 11:49:32 +01:00
Manu
0d97bfbc59 Bump pyloadapi to 2.0.0 (#164495) 2026-03-02 11:47:13 +01:00
epenet
fe830337c9 Migrate modem_callerid to runtime_data (#164566) 2026-03-02 11:45:58 +01:00
epenet
5210b7d847 Migrate moehlenhoff_alpha2 to runtime_data (#164571) 2026-03-02 11:45:10 +01:00
Mike Ryan
2f7ed4040b Bump python-fullykiosk from 0.0.14 to 0.0.15 (#164511) 2026-03-02 11:42:56 +01:00
Simone Chemelli
6376ba93a7 Bump aioamazondevices to 12.0.2 (#164518) 2026-03-02 11:37:39 +01:00
J. Nick Koston
fd3a1cc9f4 Bump yalexs-ble to 3.2.7 (#164555) 2026-03-02 11:36:05 +01:00
epenet
208013ab76 Move metoffice coordinators to separate module (#164562)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-02 11:31:57 +01:00
Alex Brown
770b3f910e Fix Matter lock credential slot iteration bound (#164478)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:56:03 +01:00
Norbert Rittel
5dce4a8eda Change one remaining string from "Overseerr" to "Seerr" (#164569) 2026-03-02 10:22:49 +01:00
Jan-Philipp Benecke
6fcc9da948 Fix large WebDAV backup metadata download (#164563)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-02 10:17:18 +01:00
epenet
bf93580ff9 Migrate modern_forms to runtime_data (#164570) 2026-03-02 10:10:03 +01:00
Jan-Philipp Benecke
0c2fe045d5 Bump aiowebdav2 to 0.6.1 (#164560) 2026-03-02 10:09:33 +01:00
Joost Lekkerkerker
e14a3a6b0e Fix SmartThings EHS power (#164395) 2026-03-02 08:35:37 +01:00
Joost Lekkerkerker
e032740e90 Add time platform to SmartThings (#164451) 2026-03-02 08:34:53 +01:00
Joost Lekkerkerker
78ad1e102d Add binary sensor for full dust bag in SmartThings (#164457) 2026-03-02 08:34:19 +01:00
Joost Lekkerkerker
4f97cc7b68 Add sound detection sensitivity select to SmartThings (#164466) 2026-03-02 08:33:47 +01:00
dependabot[bot]
df8f135532 Bump github/codeql-action from 4.32.3 to 4.32.4 (#164554) 2026-03-02 07:30:23 +01:00
J. Nick Koston
0066801b0f Bump yarl to 1.23.0 (#164542) 2026-03-02 07:22:37 +01:00
90 changed files with 1621 additions and 1353 deletions

View File

@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
category: "/language:python"

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==12.0.0"]
"requirements": ["aioamazondevices==12.0.2"]
}

View File

@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.7"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["dsmr_parser"],
"requirements": ["dsmr-parser==1.4.3"]
"requirements": ["dsmr-parser==1.5.0"]
}

View File

@@ -14,5 +14,5 @@
"iot_class": "local_polling",
"mqtt": ["fully/deviceInfo/+"],
"quality_scale": "bronze",
"requirements": ["python-fullykiosk==0.0.14"]
"requirements": ["python-fullykiosk==0.0.15"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aiogithubapi"],
"requirements": ["aiogithubapi==24.6.0"]
"requirements": ["aiogithubapi==26.0.0"]
}

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, TypedDict
from chip.clusters import Objects as clusters
from chip.clusters.Types import NullValue
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
@@ -156,11 +157,17 @@ def _get_attr(obj: Any, attr: str) -> Any:
"""Get attribute from object or dict.
Matter SDK responses can be either dataclass objects or dicts depending on
the SDK version and serialization context.
the SDK version and serialization context. NullValue (a truthy,
non-iterable singleton) is normalized to None.
"""
if isinstance(obj, dict):
return obj.get(attr)
return getattr(obj, attr, None)
value = obj.get(attr)
else:
value = getattr(obj, attr, None)
# The Matter SDK uses NullValue for nullable fields instead of None.
if value is NullValue:
return None
return value
def _get_supported_credential_types(feature_map: int) -> list[str]:
@@ -598,6 +605,13 @@ _CREDENTIAL_TYPE_FEATURE_MAP: dict[str, int] = {
CRED_TYPE_FACE: DoorLockFeature.kFaceCredentials,
}
# Map credential type strings to the capacity attribute for slot iteration.
# Biometric types have no dedicated capacity attribute; fall back to total users.
_CREDENTIAL_TYPE_CAPACITY_ATTR = {
CRED_TYPE_PIN: clusters.DoorLock.Attributes.NumberOfPINUsersSupported,
CRED_TYPE_RFID: clusters.DoorLock.Attributes.NumberOfRFIDUsersSupported,
}
def _validate_credential_type_support(
lock_endpoint: MatterEndpoint, credential_type: str
@@ -736,13 +750,15 @@ async def set_lock_credential(
operation_type = clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd
if credential_index is None:
# Auto-find first available credential slot
# Auto-find first available credential slot.
# Use the credential-type-specific capacity as the upper bound.
max_creds_attr = _CREDENTIAL_TYPE_CAPACITY_ATTR.get(
credential_type,
clusters.DoorLock.Attributes.NumberOfTotalUsersSupported,
)
max_creds_raw = lock_endpoint.get_attribute_value(None, max_creds_attr)
max_creds = (
lock_endpoint.get_attribute_value(
None,
clusters.DoorLock.Attributes.NumberOfCredentialsSupportedPerUser,
)
or 5
max_creds_raw if isinstance(max_creds_raw, int) and max_creds_raw > 0 else 5
)
for idx in range(1, max_creds + 1):
status_response = await matter_client.send_device_command(

View File

@@ -642,7 +642,7 @@
},
"services": {
"clear_lock_credential": {
"description": "Removes a credential from the lock.",
"description": "Removes a credential from a lock.",
"fields": {
"credential_index": {
"description": "The credential slot index to clear.",
@@ -666,7 +666,7 @@
"name": "Clear lock user"
},
"get_lock_credential_status": {
"description": "Returns the status of a credential slot on the lock.",
"description": "Returns the status of a credential slot on a lock.",
"fields": {
"credential_index": {
"description": "The credential slot index to query.",
@@ -684,7 +684,7 @@
"name": "Get lock info"
},
"get_lock_users": {
"description": "Returns all users configured on the lock with their credentials.",
"description": "Returns all users configured on a lock with their credentials.",
"name": "Get lock users"
},
"open_commissioning_window": {
@@ -698,7 +698,7 @@
"name": "Open commissioning window"
},
"set_lock_credential": {
"description": "Adds or updates a credential on the lock.",
"description": "Adds or updates a credential on a lock.",
"fields": {
"credential_data": {
"description": "The credential data. For PIN: digits only. For RFID: hexadecimal string.",

View File

@@ -3,19 +3,17 @@
from __future__ import annotations
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import MedcomBleUpdateCoordinator
from .coordinator import MedcomBleConfigEntry, MedcomBleUpdateCoordinator
# Supported platforms
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MedcomBleConfigEntry) -> bool:
"""Set up Medcom BLE radiation monitor from a config entry."""
address = entry.unique_id
@@ -31,16 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MedcomBleConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -18,13 +18,17 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
type MedcomBleConfigEntry = ConfigEntry[MedcomBleUpdateCoordinator]
class MedcomBleUpdateCoordinator(DataUpdateCoordinator[MedcomBleDevice]):
"""Coordinator for Medcom BLE radiation monitor data."""
config_entry: ConfigEntry
config_entry: MedcomBleConfigEntry
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, address: str) -> None:
def __init__(
self, hass: HomeAssistant, entry: MedcomBleConfigEntry, address: str
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import logging
from homeassistant import config_entries
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
@@ -15,8 +14,8 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceIn
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, UNIT_CPM
from .coordinator import MedcomBleUpdateCoordinator
from .const import UNIT_CPM
from .coordinator import MedcomBleConfigEntry, MedcomBleUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -32,12 +31,12 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
entry: MedcomBleConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Medcom BLE radiation monitor sensors."""
coordinator: MedcomBleUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
entities = []
_LOGGER.debug("got sensors: %s", coordinator.data.sensors)

View File

@@ -1,25 +1,27 @@
"""Support for Meteoclimatic weather data."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN, PLATFORMS
from .coordinator import MeteoclimaticUpdateCoordinator
from .const import PLATFORMS
from .coordinator import MeteoclimaticConfigEntry, MeteoclimaticUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(
hass: HomeAssistant, entry: MeteoclimaticConfigEntry
) -> bool:
"""Set up a Meteoclimatic entry."""
coordinator = MeteoclimaticUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: MeteoclimaticConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -14,13 +14,15 @@ from .const import CONF_STATION_CODE, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type MeteoclimaticConfigEntry = ConfigEntry[MeteoclimaticUpdateCoordinator]
class MeteoclimaticUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for Meteoclimatic weather data."""
config_entry: ConfigEntry
config_entry: MeteoclimaticConfigEntry
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
def __init__(self, hass: HomeAssistant, entry: MeteoclimaticConfigEntry) -> None:
"""Initialize the coordinator."""
self._station_code = entry.data[CONF_STATION_CODE]
super().__init__(

View File

@@ -6,7 +6,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
DEGREE,
PERCENTAGE,
@@ -21,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL
from .coordinator import MeteoclimaticUpdateCoordinator
from .coordinator import MeteoclimaticConfigEntry, MeteoclimaticUpdateCoordinator
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@@ -113,11 +112,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoclimaticConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteoclimatic sensor platform."""
coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
[MeteoclimaticSensor(coordinator, description) for description in SENSOR_TYPES],

View File

@@ -5,7 +5,6 @@ from typing import TYPE_CHECKING
from meteoclimatic import Condition
from homeassistant.components.weather import WeatherEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
@@ -13,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, CONDITION_MAP, DOMAIN, MANUFACTURER, MODEL
from .coordinator import MeteoclimaticUpdateCoordinator
from .coordinator import MeteoclimaticConfigEntry, MeteoclimaticUpdateCoordinator
def format_condition(condition):
@@ -27,11 +26,11 @@ def format_condition(condition):
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoclimaticConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteoclimatic weather platform."""
coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities([MeteoclimaticWeather(coordinator)], False)

View File

@@ -3,9 +3,7 @@
from __future__ import annotations
import asyncio
import logging
from datapoint.Forecast import Forecast
from datapoint.Manager import Manager
from homeassistant.config_entries import ConfigEntry
@@ -19,10 +17,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
from .const import (
DEFAULT_SCAN_INTERVAL,
DOMAIN,
METOFFICE_COORDINATES,
METOFFICE_DAILY_COORDINATOR,
@@ -30,9 +26,7 @@ from .const import (
METOFFICE_NAME,
METOFFICE_TWICE_DAILY_COORDINATOR,
)
from .helpers import fetch_data
_LOGGER = logging.getLogger(__name__)
from .coordinator import MetOfficeUpdateCoordinator
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
@@ -40,55 +34,43 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Met Office entry."""
latitude = entry.data[CONF_LATITUDE]
longitude = entry.data[CONF_LONGITUDE]
api_key = entry.data[CONF_API_KEY]
site_name = entry.data[CONF_NAME]
latitude: float = entry.data[CONF_LATITUDE]
longitude: float = entry.data[CONF_LONGITUDE]
api_key: str = entry.data[CONF_API_KEY]
site_name: str = entry.data[CONF_NAME]
coordinates = f"{latitude}_{longitude}"
connection = Manager(api_key=api_key)
async def async_update_hourly() -> Forecast:
return await hass.async_add_executor_job(
fetch_data, connection, latitude, longitude, "hourly"
)
async def async_update_daily() -> Forecast:
return await hass.async_add_executor_job(
fetch_data, connection, latitude, longitude, "daily"
)
async def async_update_twice_daily() -> Forecast:
return await hass.async_add_executor_job(
fetch_data, connection, latitude, longitude, "twice-daily"
)
metoffice_hourly_coordinator = TimestampDataUpdateCoordinator(
metoffice_hourly_coordinator = MetOfficeUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
entry,
name=f"MetOffice Hourly Coordinator for {site_name}",
update_method=async_update_hourly,
update_interval=DEFAULT_SCAN_INTERVAL,
connection=connection,
latitude=latitude,
longitude=longitude,
frequency="hourly",
)
metoffice_daily_coordinator = TimestampDataUpdateCoordinator(
metoffice_daily_coordinator = MetOfficeUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
entry,
name=f"MetOffice Daily Coordinator for {site_name}",
update_method=async_update_daily,
update_interval=DEFAULT_SCAN_INTERVAL,
connection=connection,
latitude=latitude,
longitude=longitude,
frequency="daily",
)
metoffice_twice_daily_coordinator = TimestampDataUpdateCoordinator(
metoffice_twice_daily_coordinator = MetOfficeUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
entry,
name=f"MetOffice Twice Daily Coordinator for {site_name}",
update_method=async_update_twice_daily,
update_interval=DEFAULT_SCAN_INTERVAL,
connection=connection,
latitude=latitude,
longitude=longitude,
frequency="twice-daily",
)
metoffice_hass_data = hass.data.setdefault(DOMAIN, {})

View File

@@ -0,0 +1,82 @@
"""Data update coordinator for the Met Office integration."""
from __future__ import annotations
import logging
from typing import Literal
from datapoint.exceptions import APIException
from datapoint.Forecast import Forecast
from datapoint.Manager import Manager
from requests import HTTPError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
)
from .const import DEFAULT_SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
class MetOfficeUpdateCoordinator(TimestampDataUpdateCoordinator[Forecast]):
"""Coordinator for Met Office forecast data."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
name: str,
connection: Manager,
latitude: float,
longitude: float,
frequency: Literal["daily", "twice-daily", "hourly"],
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=name,
config_entry=entry,
update_interval=DEFAULT_SCAN_INTERVAL,
)
self._connection = connection
self._latitude = latitude
self._longitude = longitude
self._frequency = frequency
async def _async_update_data(self) -> Forecast:
"""Get data from Met Office."""
return await self.hass.async_add_executor_job(
fetch_data,
self._connection,
self._latitude,
self._longitude,
self._frequency,
)
def fetch_data(
connection: Manager,
latitude: float,
longitude: float,
frequency: Literal["daily", "twice-daily", "hourly"],
) -> Forecast:
"""Fetch weather and forecast from Datapoint API."""
try:
return connection.get_forecast(
latitude, longitude, frequency, convert_weather_code=False
)
except (ValueError, APIException) as err:
_LOGGER.error("Check Met Office connection: %s", err.args)
raise UpdateFailed from err
except HTTPError as err:
if err.response.status_code == 401:
raise ConfigEntryAuthFailed from err
raise

View File

@@ -2,38 +2,7 @@
from __future__ import annotations
import logging
from typing import Any, Literal
from datapoint.exceptions import APIException
from datapoint.Forecast import Forecast
from datapoint.Manager import Manager
from requests import HTTPError
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import UpdateFailed
_LOGGER = logging.getLogger(__name__)
def fetch_data(
connection: Manager,
latitude: float,
longitude: float,
frequency: Literal["daily", "twice-daily", "hourly"],
) -> Forecast:
"""Fetch weather and forecast from Datapoint API."""
try:
return connection.get_forecast(
latitude, longitude, frequency, convert_weather_code=False
)
except (ValueError, APIException) as err:
_LOGGER.error("Check Met Office connection: %s", err.args)
raise UpdateFailed from err
except HTTPError as err:
if err.response.status_code == 401:
raise ConfigEntryAuthFailed from err
raise
from typing import Any
def get_attribute(data: dict[str, Any] | None, attr_name: str) -> Any | None:

View File

@@ -5,8 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from datapoint.Forecast import Forecast
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
EntityCategory,
@@ -29,10 +27,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import get_device_info
from .const import (
@@ -43,6 +38,7 @@ from .const import (
METOFFICE_HOURLY_COORDINATOR,
METOFFICE_NAME,
)
from .coordinator import MetOfficeUpdateCoordinator
from .helpers import get_attribute
ATTR_LAST_UPDATE = "last_update"
@@ -220,7 +216,7 @@ async def async_setup_entry(
class MetOfficeCurrentSensor(
CoordinatorEntity[DataUpdateCoordinator[Forecast]], SensorEntity
CoordinatorEntity[MetOfficeUpdateCoordinator], SensorEntity
):
"""Implementation of a Met Office current weather condition sensor."""
@@ -231,7 +227,7 @@ class MetOfficeCurrentSensor(
def __init__(
self,
coordinator: DataUpdateCoordinator[Forecast],
coordinator: MetOfficeUpdateCoordinator,
hass_data: dict[str, Any],
description: MetOfficeSensorEntityDescription,
) -> None:

View File

@@ -5,8 +5,6 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, cast
from datapoint.Forecast import Forecast
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_IS_DAYTIME,
@@ -35,7 +33,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
from . import get_device_info
from .const import (
@@ -52,6 +49,7 @@ from .const import (
METOFFICE_TWICE_DAILY_COORDINATOR,
NIGHT_FORECAST_ATTRIBUTE_MAP,
)
from .coordinator import MetOfficeUpdateCoordinator
from .helpers import get_attribute
@@ -153,9 +151,9 @@ def _populate_forecast_data(
class MetOfficeWeather(
CoordinatorWeatherEntity[
TimestampDataUpdateCoordinator[Forecast],
TimestampDataUpdateCoordinator[Forecast],
TimestampDataUpdateCoordinator[Forecast],
MetOfficeUpdateCoordinator,
MetOfficeUpdateCoordinator,
MetOfficeUpdateCoordinator,
]
):
"""Implementation of a Met Office weather condition."""
@@ -177,9 +175,9 @@ class MetOfficeWeather(
def __init__(
self,
coordinator_daily: TimestampDataUpdateCoordinator[Forecast],
coordinator_hourly: TimestampDataUpdateCoordinator[Forecast],
coordinator_twice_daily: TimestampDataUpdateCoordinator[Forecast],
coordinator_daily: MetOfficeUpdateCoordinator,
coordinator_hourly: MetOfficeUpdateCoordinator,
coordinator_twice_daily: MetOfficeUpdateCoordinator,
hass_data: dict[str, Any],
) -> None:
"""Initialise the platform with a data instance."""
@@ -266,7 +264,7 @@ class MetOfficeWeather(
def _async_forecast_daily(self) -> list[WeatherForecast] | None:
"""Return the daily forecast in native units."""
coordinator = cast(
TimestampDataUpdateCoordinator[Forecast],
MetOfficeUpdateCoordinator,
self.forecast_coordinators["daily"],
)
timesteps = coordinator.data.timesteps
@@ -283,7 +281,7 @@ class MetOfficeWeather(
def _async_forecast_hourly(self) -> list[WeatherForecast] | None:
"""Return the hourly forecast in native units."""
coordinator = cast(
TimestampDataUpdateCoordinator[Forecast],
MetOfficeUpdateCoordinator,
self.forecast_coordinators["hourly"],
)
@@ -301,7 +299,7 @@ class MetOfficeWeather(
def _async_forecast_twice_daily(self) -> list[WeatherForecast] | None:
"""Return the twice daily forecast in native units."""
coordinator = cast(
TimestampDataUpdateCoordinator[Forecast],
MetOfficeUpdateCoordinator,
self.forecast_coordinators["twice_daily"],
)
timesteps = coordinator.data.timesteps

View File

@@ -13,22 +13,25 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, PLATFORMS
from .const import PLATFORMS
from .coordinator import MicroBeesUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type MicroBeesConfigEntry = ConfigEntry[HomeAssistantMicroBeesData]
@dataclass(frozen=True, kw_only=True)
class HomeAssistantMicroBeesData:
"""Microbees data stored in the Home Assistant data object."""
"""Microbees data stored in the config entry runtime_data."""
connector: MicroBees
coordinator: MicroBeesUpdateCoordinator
session: config_entry_oauth2_flow.OAuth2Session
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
@@ -45,7 +48,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) -> bool:
"""Set up microBees from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
@@ -67,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
microbees = MicroBees(token=session.token[CONF_ACCESS_TOKEN])
coordinator = MicroBeesUpdateCoordinator(hass, entry, microbees)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantMicroBeesData(
entry.runtime_data = HomeAssistantMicroBeesData(
connector=microbees,
coordinator=coordinator,
session=session,
@@ -76,9 +79,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -7,11 +7,10 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from . import MicroBeesConfigEntry
from .coordinator import MicroBeesUpdateCoordinator
from .entity import MicroBeesEntity
@@ -37,13 +36,11 @@ BINARYSENSOR_TYPES = {
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MicroBeesConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the microBees binary sensor platform."""
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
].coordinator
coordinator = entry.runtime_data.coordinator
async_add_entities(
MBBinarySensor(coordinator, entity_description, bee_id, binary_sensor.id)
for bee_id, bee in coordinator.data.bees.items()

View File

@@ -3,11 +3,10 @@
from typing import Any
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from . import MicroBeesConfigEntry
from .coordinator import MicroBeesUpdateCoordinator
from .entity import MicroBeesActuatorEntity
@@ -16,13 +15,11 @@ BUTTON_TRANSLATIONS = {51: "button_gate", 91: "button_panic"}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MicroBeesConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the microBees button platform."""
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
].coordinator
coordinator = entry.runtime_data.coordinator
async_add_entities(
MBButton(coordinator, bee_id, button.id)
for bee_id, bee in coordinator.data.bees.items()

View File

@@ -7,13 +7,12 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from . import MicroBeesConfigEntry
from .coordinator import MicroBeesUpdateCoordinator
from .entity import MicroBeesActuatorEntity
@@ -27,13 +26,11 @@ THERMOVALVE_SENSOR_ID = 782
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MicroBeesConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the microBees climate platform."""
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
].coordinator
coordinator = entry.runtime_data.coordinator
async_add_entities(
MBClimate(
coordinator,

View File

@@ -1,19 +1,24 @@
"""The microBees Coordinator."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import timedelta
from http import HTTPStatus
import logging
from typing import TYPE_CHECKING
import aiohttp
from microBeesPy import Actuator, Bee, MicroBees, MicroBeesException, Sensor
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
if TYPE_CHECKING:
from . import MicroBeesConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -29,10 +34,13 @@ class MicroBeesCoordinatorData:
class MicroBeesUpdateCoordinator(DataUpdateCoordinator[MicroBeesCoordinatorData]):
"""MicroBees coordinator."""
config_entry: ConfigEntry
config_entry: MicroBeesConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, microbees: MicroBees
self,
hass: HomeAssistant,
config_entry: MicroBeesConfigEntry,
microbees: MicroBees,
) -> None:
"""Initialize microBees coordinator."""
super().__init__(

View File

@@ -9,14 +9,12 @@ from homeassistant.components.cover import (
CoverEntity,
CoverEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from .const import DOMAIN
from .coordinator import MicroBeesUpdateCoordinator
from . import MicroBeesConfigEntry
from .entity import MicroBeesEntity
COVER_IDS = {47: "roller_shutter"}
@@ -24,13 +22,11 @@ COVER_IDS = {47: "roller_shutter"}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MicroBeesConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the microBees cover platform."""
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
].coordinator
coordinator = entry.runtime_data.coordinator
async_add_entities(
MBCover(

View File

@@ -3,25 +3,22 @@
from typing import Any
from homeassistant.components.light import ATTR_RGBW_COLOR, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from . import MicroBeesConfigEntry
from .coordinator import MicroBeesUpdateCoordinator
from .entity import MicroBeesActuatorEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MicroBeesConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Config entry."""
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
].coordinator
coordinator = entry.runtime_data.coordinator
async_add_entities(
MBLight(coordinator, bee_id, light.id)
for bee_id, bee in coordinator.data.bees.items()

View File

@@ -8,7 +8,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
@@ -19,7 +18,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from . import MicroBeesConfigEntry
from .coordinator import MicroBeesUpdateCoordinator
from .entity import MicroBeesEntity
@@ -64,11 +63,11 @@ SENSOR_TYPES = {
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MicroBeesConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id].coordinator
coordinator = entry.runtime_data.coordinator
async_add_entities(
MBSensor(coordinator, desc, bee_id, sensor.id)

View File

@@ -3,12 +3,11 @@
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from . import MicroBeesConfigEntry
from .coordinator import MicroBeesUpdateCoordinator
from .entity import MicroBeesActuatorEntity
@@ -18,11 +17,11 @@ SWITCH_PRODUCT_IDS = {25, 26, 27, 35, 38, 46, 63, 64, 65, 86}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MicroBeesConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id].coordinator
coordinator = entry.runtime_data.coordinator
async_add_entities(
MBSwitch(coordinator, bee_id, switch.id)

View File

@@ -3,16 +3,20 @@
from phone_modem import PhoneModem
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DATA_KEY_API, DOMAIN, EXCEPTIONS
from .const import EXCEPTIONS
PLATFORMS = [Platform.BUTTON, Platform.SENSOR]
type ModemCallerIdConfigEntry = ConfigEntry[PhoneModem]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(
hass: HomeAssistant, entry: ModemCallerIdConfigEntry
) -> bool:
"""Set up Modem Caller ID from a config entry."""
device = entry.data[CONF_DEVICE]
api = PhoneModem(device)
@@ -21,17 +25,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except EXCEPTIONS as ex:
raise ConfigEntryNotReady(f"Unable to open port: {device}") from ex
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_KEY_API: api}
entry.async_on_unload(api.close)
async def _async_on_hass_stop(event: Event) -> None:
"""HA is shutting down, close modem port."""
api.close()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_on_hass_stop)
)
entry.runtime_data = api
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: ModemCallerIdConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
api = hass.data[DOMAIN].pop(entry.entry_id)[DATA_KEY_API]
await api.close()
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -5,26 +5,25 @@ from __future__ import annotations
from phone_modem import PhoneModem
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_KEY_API, DOMAIN
from . import ModemCallerIdConfigEntry
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: ModemCallerIdConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Modem Caller ID sensor."""
api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API]
async_add_entities(
[
PhoneModemButton(
api,
entry.runtime_data,
entry.data[CONF_DEVICE],
entry.entry_id,
)

View File

@@ -5,7 +5,6 @@ from typing import Final
from phone_modem import exceptions
from serial import SerialException
DATA_KEY_API = "api"
DEFAULT_NAME = "Phone Modem"
DOMAIN = "modem_callerid"

View File

@@ -5,40 +5,30 @@ from __future__ import annotations
from phone_modem import PhoneModem
from homeassistant.components.sensor import RestoreSensor
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.const import STATE_IDLE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CID, DATA_KEY_API, DOMAIN
from . import ModemCallerIdConfigEntry
from .const import CID, DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: ModemCallerIdConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Modem Caller ID sensor."""
api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API]
async_add_entities(
[
ModemCalleridSensor(
api,
entry.runtime_data,
entry.entry_id,
)
]
)
async def _async_on_hass_stop(event: Event) -> None:
"""HA is shutting down, close modem port."""
if hass.data[DOMAIN][entry.entry_id][DATA_KEY_API]:
await hass.data[DOMAIN][entry.entry_id][DATA_KEY_API].close()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_on_hass_stop)
)
class ModemCalleridSensor(RestoreSensor):
"""Implementation of USB modem caller ID sensor."""

View File

@@ -8,12 +8,10 @@ from typing import Any, Concatenate
from aiomodernforms import ModernFormsConnectionError, ModernFormsError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import ModernFormsDataUpdateCoordinator
from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator
from .entity import ModernFormsDeviceEntity
PLATFORMS = [
@@ -26,15 +24,14 @@ PLATFORMS = [
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ModernFormsConfigEntry) -> bool:
"""Set up a Modern Forms device from a config entry."""
# Create Modern Forms instance for this entry
coordinator = ModernFormsDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
entry.runtime_data = coordinator
# Set up all platforms for this device/entry.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -42,17 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: ModernFormsConfigEntry
) -> bool:
"""Unload Modern Forms config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
del hass.data[DOMAIN][entry.entry_id]
if not hass.data[DOMAIN]:
del hass.data[DOMAIN]
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def modernforms_exception_handler[

View File

@@ -3,23 +3,22 @@
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import CLEAR_TIMER, DOMAIN
from .coordinator import ModernFormsDataUpdateCoordinator
from .const import CLEAR_TIMER
from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator
from .entity import ModernFormsDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: ModernFormsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Modern Forms binary sensors."""
coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
binary_sensors: list[ModernFormsBinarySensor] = [
ModernFormsFanSleepTimerActive(entry.entry_id, coordinator),

View File

@@ -20,6 +20,9 @@ SCAN_INTERVAL = timedelta(seconds=5)
_LOGGER = logging.getLogger(__name__)
type ModernFormsConfigEntry = ConfigEntry[ModernFormsDataUpdateCoordinator]
class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]):
"""Class to manage fetching Modern Forms data from single endpoint."""

View File

@@ -3,27 +3,23 @@
from __future__ import annotations
from dataclasses import asdict
from typing import TYPE_CHECKING, Any
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MAC
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import ModernFormsDataUpdateCoordinator
from .coordinator import ModernFormsConfigEntry
REDACT_CONFIG = {CONF_MAC}
REDACT_DEVICE_INFO = {"mac_address", "owner"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
hass: HomeAssistant, entry: ModernFormsConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
if TYPE_CHECKING:
assert coordinator is not None
coordinator = entry.runtime_data
return {
"config_entry": async_redact_data(entry.as_dict(), REDACT_CONFIG),

View File

@@ -8,7 +8,6 @@ from aiomodernforms.const import FAN_POWER_OFF, FAN_POWER_ON
import voluptuous as vol
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -22,26 +21,23 @@ from . import modernforms_exception_handler
from .const import (
ATTR_SLEEP_TIME,
CLEAR_TIMER,
DOMAIN,
OPT_ON,
OPT_SPEED,
SERVICE_CLEAR_FAN_SLEEP_TIMER,
SERVICE_SET_FAN_SLEEP_TIMER,
)
from .coordinator import ModernFormsDataUpdateCoordinator
from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator
from .entity import ModernFormsDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: ModernFormsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Modern Forms platform from config entry."""
coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinator = config_entry.runtime_data
platform = entity_platform.async_get_current_platform()

View File

@@ -8,7 +8,6 @@ from aiomodernforms.const import LIGHT_POWER_OFF, LIGHT_POWER_ON
import voluptuous as vol
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -21,13 +20,12 @@ from . import modernforms_exception_handler
from .const import (
ATTR_SLEEP_TIME,
CLEAR_TIMER,
DOMAIN,
OPT_BRIGHTNESS,
OPT_ON,
SERVICE_CLEAR_LIGHT_SLEEP_TIMER,
SERVICE_SET_LIGHT_SLEEP_TIMER,
)
from .coordinator import ModernFormsDataUpdateCoordinator
from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator
from .entity import ModernFormsDeviceEntity
BRIGHTNESS_RANGE = (1, 255)
@@ -35,14 +33,12 @@ BRIGHTNESS_RANGE = (1, 255)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: ModernFormsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Modern Forms platform from config entry."""
coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinator = config_entry.runtime_data
# if no light unit installed no light entity
if not coordinator.data.info.light_type:

View File

@@ -5,24 +5,23 @@ from __future__ import annotations
from datetime import datetime
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from .const import CLEAR_TIMER, DOMAIN
from .coordinator import ModernFormsDataUpdateCoordinator
from .const import CLEAR_TIMER
from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator
from .entity import ModernFormsDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: ModernFormsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Modern Forms sensor based on a config entry."""
coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
sensors: list[ModernFormsSensor] = [
ModernFormsFanTimerRemainingTimeSensor(entry.entry_id, coordinator),

View File

@@ -5,23 +5,21 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import modernforms_exception_handler
from .const import DOMAIN
from .coordinator import ModernFormsDataUpdateCoordinator
from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator
from .entity import ModernFormsDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: ModernFormsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Modern Forms switch based on a config entry."""
coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
switches = [
ModernFormsAwaySwitch(entry.entry_id, coordinator),

View File

@@ -4,41 +4,33 @@ from __future__ import annotations
from moehlenhoff_alpha2 import Alpha2Base
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import Alpha2BaseCoordinator
from .coordinator import Alpha2BaseCoordinator, Alpha2ConfigEntry
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: Alpha2ConfigEntry) -> bool:
"""Set up a config entry."""
base = Alpha2Base(entry.data[CONF_HOST])
coordinator = Alpha2BaseCoordinator(hass, entry, base)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: Alpha2ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok and entry.entry_id in hass.data[DOMAIN]:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def update_listener(hass: HomeAssistant, entry: Alpha2ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -4,24 +4,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import Alpha2BaseCoordinator
from .coordinator import Alpha2BaseCoordinator, Alpha2ConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: Alpha2ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Alpha2 sensor entities from a config_entry."""
coordinator: Alpha2BaseCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
async_add_entities(
Alpha2IODeviceBatterySensor(coordinator, io_device_id)

View File

@@ -1,25 +1,23 @@
"""Button entity to set the time of the Alpha2 base."""
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import Alpha2BaseCoordinator
from .coordinator import Alpha2BaseCoordinator, Alpha2ConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: Alpha2ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Alpha2 button entities."""
coordinator: Alpha2BaseCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
async_add_entities([Alpha2TimeSyncButton(coordinator, config_entry.entry_id)])

View File

@@ -1,6 +1,5 @@
"""Support for Alpha2 room control unit via Alpha2 base."""
import logging
from typing import Any
from homeassistant.components.climate import (
@@ -9,26 +8,23 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, PRESET_AUTO, PRESET_DAY, PRESET_NIGHT
from .coordinator import Alpha2BaseCoordinator
_LOGGER = logging.getLogger(__name__)
from .const import PRESET_AUTO, PRESET_DAY, PRESET_NIGHT
from .coordinator import Alpha2BaseCoordinator, Alpha2ConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: Alpha2ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Alpha2Climate entities from a config_entry."""
coordinator: Alpha2BaseCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
async_add_entities(
Alpha2Climate(coordinator, heat_area_id)

View File

@@ -17,14 +17,16 @@ _LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=60)
type Alpha2ConfigEntry = ConfigEntry[Alpha2BaseCoordinator]
class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]):
"""Keep the base instance in one place and centralize the update."""
config_entry: ConfigEntry
config_entry: Alpha2ConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, base: Alpha2Base
self, hass: HomeAssistant, config_entry: Alpha2ConfigEntry, base: Alpha2Base
) -> None:
"""Initialize Alpha2Base data updater."""
self.base = base

View File

@@ -1,24 +1,22 @@
"""Support for Alpha2 heat control valve opening sensors."""
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import Alpha2BaseCoordinator
from .coordinator import Alpha2BaseCoordinator, Alpha2ConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: Alpha2ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Alpha2 sensor entities from a config_entry."""
coordinator: Alpha2BaseCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
# HEATCTRL attribute ACTOR_PERCENT is not available in older firmware versions
async_add_entities(

View File

@@ -56,7 +56,6 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ATTR_EVENT_TYPE,
@@ -69,7 +68,6 @@ from .const import (
CONF_SURVEILLANCE_USERNAME,
CONF_WEBHOOK_SET,
CONF_WEBHOOK_SET_OVERWRITE,
DEFAULT_SCAN_INTERVAL,
DEFAULT_WEBHOOK_SET,
DEFAULT_WEBHOOK_SET_OVERWRITE,
DOMAIN,
@@ -84,6 +82,7 @@ from .const import (
WEB_HOOK_SENTINEL_KEY,
WEB_HOOK_SENTINEL_VALUE,
)
from .coordinator import MotionEyeUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN]
@@ -308,20 +307,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass, DOMAIN, "motionEye", entry.data[CONF_WEBHOOK_ID], handle_webhook
)
async def async_update_data() -> dict[str, Any] | None:
try:
return await client.async_get_cameras()
except MotionEyeClientError as exc:
raise UpdateFailed("Error communicating with API") from exc
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_method=async_update_data,
update_interval=DEFAULT_SCAN_INTERVAL,
)
coordinator = MotionEyeUpdateCoordinator(hass, entry, client)
hass.data[DOMAIN][entry.entry_id] = {
CONF_CLIENT: client,
CONF_COORDINATOR: coordinator,

View File

@@ -43,7 +43,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import get_camera_from_cameras, is_acceptable_camera, listen_for_new_cameras
from .const import (
@@ -60,6 +59,7 @@ from .const import (
SERVICE_SNAPSHOT,
TYPE_MOTIONEYE_MJPEG_CAMERA,
)
from .coordinator import MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
PLATFORMS = [Platform.CAMERA]
@@ -153,7 +153,7 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera):
password: str,
camera: dict[str, Any],
client: MotionEyeClient,
coordinator: DataUpdateCoordinator,
coordinator: MotionEyeUpdateCoordinator,
options: Mapping[str, str],
) -> None:
"""Initialize a MJPEG camera."""

View File

@@ -0,0 +1,41 @@
"""Coordinator for the motionEye integration."""
from __future__ import annotations
import logging
from typing import Any
from motioneye_client.client import MotionEyeClient, MotionEyeClientError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
class MotionEyeUpdateCoordinator(DataUpdateCoordinator[dict[str, Any] | None]):
"""Coordinator for motionEye data."""
config_entry: ConfigEntry
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, client: MotionEyeClient
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
config_entry=entry,
update_interval=DEFAULT_SCAN_INTERVAL,
)
self.client = client
async def _async_update_data(self) -> dict[str, Any] | None:
try:
return await self.client.async_get_cameras()
except MotionEyeClientError as exc:
raise UpdateFailed("Error communicating with API") from exc

View File

@@ -10,12 +10,10 @@ from motioneye_client.const import KEY_ID
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import get_motioneye_device_identifier
from .coordinator import MotionEyeUpdateCoordinator
def get_motioneye_entity_unique_id(
@@ -25,7 +23,7 @@ def get_motioneye_entity_unique_id(
return f"{config_entry_id}_{camera_id}_{entity_type}"
class MotionEyeEntity(CoordinatorEntity):
class MotionEyeEntity(CoordinatorEntity[MotionEyeUpdateCoordinator]):
"""Base class for motionEye entities."""
_attr_has_entity_name = True
@@ -36,7 +34,7 @@ class MotionEyeEntity(CoordinatorEntity):
type_name: str,
camera: dict[str, Any],
client: MotionEyeClient,
coordinator: DataUpdateCoordinator,
coordinator: MotionEyeUpdateCoordinator,
options: Mapping[str, Any],
entity_description: EntityDescription | None = None,
) -> None:

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from motioneye_client.client import MotionEyeClient
@@ -14,14 +13,12 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import get_camera_from_cameras, listen_for_new_cameras
from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR
from .coordinator import MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
@@ -59,7 +56,7 @@ class MotionEyeActionSensor(MotionEyeEntity, SensorEntity):
config_entry_id: str,
camera: dict[str, Any],
client: MotionEyeClient,
coordinator: DataUpdateCoordinator,
coordinator: MotionEyeUpdateCoordinator,
options: Mapping[str, str],
) -> None:
"""Initialize an action sensor."""

View File

@@ -20,10 +20,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import get_camera_from_cameras, listen_for_new_cameras
from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE
from .coordinator import MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
MOTIONEYE_SWITCHES = [
@@ -102,7 +102,7 @@ class MotionEyeSwitch(MotionEyeEntity, SwitchEntity):
config_entry_id: str,
camera: dict[str, Any],
client: MotionEyeClient,
coordinator: DataUpdateCoordinator,
coordinator: MotionEyeUpdateCoordinator,
options: Mapping[str, str],
entity_description: SwitchEntityDescription,
) -> None:

View File

@@ -114,7 +114,7 @@
"message": "[%key:common::config_flow::error::invalid_api_key%]"
},
"connection_error": {
"message": "Error connecting to the Overseerr instance: {error}"
"message": "Error connecting to the Seerr instance: {error}"
}
},
"selector": {

View File

@@ -90,7 +90,7 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non
password=user_input[CONF_PASSWORD],
)
await pyload.login()
await pyload.get_status()
class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN):

View File

@@ -64,19 +64,12 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
**await self.pyload.get_status(),
free_space=await self.pyload.free_space(),
)
except InvalidAuth:
try:
await self.pyload.login()
except InvalidAuth as exc:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="setup_authentication_exception",
translation_placeholders={CONF_USERNAME: self.pyload.username},
) from exc
_LOGGER.debug(
"Unable to retrieve data due to cookie expiration, retrying after 20 seconds"
)
return self.data
except InvalidAuth as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="setup_authentication_exception",
translation_placeholders={CONF_USERNAME: self.pyload.username},
) from e
except CannotConnect as e:
raise UpdateFailed(
translation_domain=DOMAIN,
@@ -92,7 +85,6 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
"""Set up the coordinator."""
try:
await self.pyload.login()
self.version = await self.pyload.version()
except CannotConnect as e:
raise ConfigEntryNotReady(

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["pyloadapi"],
"quality_scale": "platinum",
"requirements": ["PyLoadAPI==1.4.2"]
"requirements": ["PyLoadAPI==2.0.0"]
}

View File

@@ -107,6 +107,7 @@ PLATFORMS = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
Platform.UPDATE,
Platform.VACUUM,
Platform.VALVE,

View File

@@ -36,6 +36,7 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription):
| None
) = None
component_translation_key: dict[str, str] | None = None
supported_states_attributes: Attribute | None = None
CAPABILITY_TO_SENSORS: dict[
@@ -188,6 +189,17 @@ CAPABILITY_TO_SENSORS: dict[
},
)
},
Capability.SAMSUNG_CE_ROBOT_CLEANER_DUST_BAG: {
Attribute.STATUS: SmartThingsBinarySensorEntityDescription(
key=Attribute.STATUS,
is_on_key="full",
component_translation_key={
"station": "robot_cleaner_dust_bag",
},
exists_fn=lambda component, _: component == "station",
supported_states_attributes=Attribute.SUPPORTED_STATUS,
)
},
}
@@ -237,6 +249,18 @@ async def async_setup_entry(
not description.category
or get_main_component_category(device) in description.category
)
and (
not description.supported_states_attributes
or (
isinstance(
options := device.status[component][capability][
description.supported_states_attributes
].value,
list,
)
and len(options) == 2
)
)
)
)

View File

@@ -21,6 +21,9 @@
"state": {
"on": "mdi:remote"
}
},
"robot_cleaner_dust_bag": {
"default": "mdi:delete"
}
},
"button": {
@@ -99,6 +102,9 @@
"robot_cleaner_driving_mode": {
"default": "mdi:car-cog"
},
"robot_cleaner_water_spray_level": {
"default": "mdi:spray-bottle"
},
"selected_zone": {
"state": {
"all": "mdi:card",
@@ -110,6 +116,9 @@
"soil_level": {
"default": "mdi:liquid-spot"
},
"sound_detection_sensitivity": {
"default": "mdi:home-sound-in"
},
"spin_level": {
"default": "mdi:rotate-right"
},
@@ -250,6 +259,14 @@
"off": "mdi:tumble-dryer-off"
}
}
},
"time": {
"do_not_disturb_end_time": {
"default": "mdi:bell-ring"
},
"do_not_disturb_start_time": {
"default": "mdi:bell-cancel"
}
}
}
}

View File

@@ -43,6 +43,14 @@ WASHER_SOIL_LEVEL_TO_HA = {
"down": "down",
}
WATER_SPRAY_LEVEL_TO_HA = {
"high": "high",
"mediumHigh": "moderate_high",
"medium": "medium",
"mediumLow": "moderate_low",
"low": "low",
}
WASHER_SPIN_LEVEL_TO_HA = {
"none": "none",
"rinseHold": "rinse_hold",
@@ -165,6 +173,15 @@ CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = {
extra_components=["hood"],
capability_ignore_list=[Capability.SAMSUNG_CE_CONNECTION_STATE],
),
Capability.SAMSUNG_CE_SOUND_DETECTION_SENSITIVITY: SmartThingsSelectDescription(
key=Capability.SAMSUNG_CE_SOUND_DETECTION_SENSITIVITY,
translation_key="sound_detection_sensitivity",
options_attribute=Attribute.SUPPORTED_LEVELS,
status_attribute=Attribute.LEVEL,
command=Command.SET_LEVEL,
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
),
Capability.CUSTOM_WASHER_SPIN_LEVEL: SmartThingsSelectDescription(
key=Capability.CUSTOM_WASHER_SPIN_LEVEL,
translation_key="spin_level",
@@ -193,6 +210,15 @@ CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = {
options_map=WASHER_WATER_TEMPERATURE_TO_HA,
entity_category=EntityCategory.CONFIG,
),
Capability.SAMSUNG_CE_ROBOT_CLEANER_WATER_SPRAY_LEVEL: SmartThingsSelectDescription(
key=Capability.SAMSUNG_CE_ROBOT_CLEANER_WATER_SPRAY_LEVEL,
translation_key="robot_cleaner_water_spray_level",
options_attribute=Attribute.SUPPORTED_WATER_SPRAY_LEVELS,
status_attribute=Attribute.WATER_SPRAY_LEVEL,
command=Command.SET_WATER_SPRAY_LEVEL,
options_map=WATER_SPRAY_LEVEL_TO_HA,
entity_category=EntityCategory.CONFIG,
),
Capability.SAMSUNG_CE_ROBOT_CLEANER_DRIVING_MODE: SmartThingsSelectDescription(
key=Capability.SAMSUNG_CE_ROBOT_CLEANER_DRIVING_MODE,
translation_key="robot_cleaner_driving_mode",

View File

@@ -162,6 +162,13 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription):
use_temperature_unit: bool = False
deprecated: Callable[[ComponentStatus], tuple[str, str] | None] | None = None
component_translation_key: dict[str, str] | None = None
presentation_fn: (
Callable[
[str | None, str | float | int | datetime | None],
str | float | int | datetime | None,
]
| None
) = None
CAPABILITY_TO_SENSORS: dict[
@@ -763,6 +770,13 @@ CAPABILITY_TO_SENSORS: dict[
(value := cast(dict | None, status.value)) is not None
and "power" in value
),
presentation_fn=lambda presentation_id, value: (
value * 1000
if presentation_id is not None
and "EHS" in presentation_id
and isinstance(value, (int, float))
else value
),
),
SmartThingsSensorEntityDescription(
key="deltaEnergy_meter",
@@ -1347,7 +1361,12 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity):
res = self.get_attribute_value(self.capability, self._attribute)
if options_map := self.entity_description.options_map:
return options_map.get(res)
return self.entity_description.value_fn(res)
value = self.entity_description.value_fn(res)
if self.entity_description.presentation_fn:
value = self.entity_description.presentation_fn(
self.device.device.presentation_id, value
)
return value
@property
def native_unit_of_measurement(self) -> str | None:

View File

@@ -76,6 +76,9 @@
"remote_control": {
"name": "Remote control"
},
"robot_cleaner_dust_bag": {
"name": "Dust bag full"
},
"sub_remote_control": {
"name": "Upper washer remote control"
},
@@ -234,6 +237,16 @@
"walls_first": "Walls first"
}
},
"robot_cleaner_water_spray_level": {
"name": "Water level",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "Medium",
"moderate_high": "Moderate high",
"moderate_low": "Moderate low"
}
},
"selected_zone": {
"name": "Selected zone",
"state": {
@@ -256,6 +269,14 @@
"up": "Up"
}
},
"sound_detection_sensitivity": {
"name": "Sound detection sensitivity",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]"
}
},
"spin_level": {
"name": "Spin level",
"state": {
@@ -934,6 +955,14 @@
"name": "Wrinkle prevent"
}
},
"time": {
"do_not_disturb_end_time": {
"name": "Do not disturb end time"
},
"do_not_disturb_start_time": {
"name": "Do not disturb start time"
}
},
"vacuum": {
"vacuum": {
"state_attributes": {

View File

@@ -0,0 +1,102 @@
"""Time platform for SmartThings."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import time
from pysmartthings import Attribute, Capability, Command, SmartThings
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullDevice, SmartThingsConfigEntry
from .const import MAIN
from .entity import SmartThingsEntity
@dataclass(frozen=True, kw_only=True)
class SmartThingsTimeEntityDescription(TimeEntityDescription):
"""Describe a SmartThings time entity."""
attribute: Attribute
DND_ENTITIES = [
SmartThingsTimeEntityDescription(
key=Attribute.START_TIME,
translation_key="do_not_disturb_start_time",
attribute=Attribute.START_TIME,
entity_category=EntityCategory.CONFIG,
),
SmartThingsTimeEntityDescription(
key=Attribute.END_TIME,
translation_key="do_not_disturb_end_time",
attribute=Attribute.END_TIME,
entity_category=EntityCategory.CONFIG,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: SmartThingsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add time entities for a config entry."""
entry_data = entry.runtime_data
async_add_entities(
SmartThingsDnDTime(entry_data.client, device, description)
for device in entry_data.devices.values()
if Capability.CUSTOM_DO_NOT_DISTURB_MODE in device.status.get(MAIN, {})
for description in DND_ENTITIES
)
class SmartThingsDnDTime(SmartThingsEntity, TimeEntity):
"""Define a SmartThings time entity."""
entity_description: SmartThingsTimeEntityDescription
def __init__(
self,
client: SmartThings,
device: FullDevice,
entity_description: SmartThingsTimeEntityDescription,
) -> None:
"""Initialize the time entity."""
super().__init__(client, device, {Capability.CUSTOM_DO_NOT_DISTURB_MODE})
self.entity_description = entity_description
self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_DO_NOT_DISTURB_MODE}_{entity_description.attribute}_{entity_description.attribute}"
async def async_set_value(self, value: time) -> None:
"""Set the time value."""
payload = {
"mode": self.get_attribute_value(
Capability.CUSTOM_DO_NOT_DISTURB_MODE, Attribute.DO_NOT_DISTURB
),
"startTime": self.get_attribute_value(
Capability.CUSTOM_DO_NOT_DISTURB_MODE, Attribute.START_TIME
),
"endTime": self.get_attribute_value(
Capability.CUSTOM_DO_NOT_DISTURB_MODE, Attribute.END_TIME
),
}
await self.execute_device_command(
Capability.CUSTOM_DO_NOT_DISTURB_MODE,
Command.SET_DO_NOT_DISTURB_MODE,
{
**payload,
self.entity_description.attribute: f"{value.hour:02d}{value.minute:02d}",
},
)
@property
def native_value(self) -> time:
"""Return the time value."""
state = self.get_attribute_value(
Capability.CUSTOM_DO_NOT_DISTURB_MODE, self.entity_description.attribute
)
return time(int(state[:2]), int(state[3:5]))

View File

@@ -222,8 +222,10 @@ class WebDavBackupAgent(BackupAgent):
async def _download_metadata(path: str) -> AgentBackup:
"""Download metadata file."""
iterator = await self._client.download_iter(path)
metadata = await anext(iterator)
return AgentBackup.from_dict(json_loads_object(metadata))
metadata_bytes = bytearray()
async for chunk in iterator:
metadata_bytes.extend(chunk)
return AgentBackup.from_dict(json_loads_object(metadata_bytes))
async def _list_metadata_files() -> dict[str, AgentBackup]:
"""List metadata files."""

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aiowebdav2"],
"quality_scale": "bronze",
"requirements": ["aiowebdav2==0.5.0"]
"requirements": ["aiowebdav2==0.6.1"]
}

View File

@@ -14,5 +14,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["socketio", "engineio", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.7"]
}

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/yalexs_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["yalexs-ble==3.2.4"]
"requirements": ["yalexs-ble==3.2.7"]
}

View File

@@ -3,7 +3,7 @@
aiodhcpwatcher==1.2.1
aiodiscover==2.7.1
aiodns==4.0.0
aiogithubapi==24.6.0
aiogithubapi==26.0.0
aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
@@ -76,7 +76,7 @@ voluptuous-openapi==0.2.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
webrtc-models==0.3.0
yarl==1.22.0
yarl==1.23.0
zeroconf==0.148.0
# Constrain pycryptodome to avoid vulnerability

View File

@@ -27,7 +27,7 @@ dependencies = [
# aiogithubapi is needed by frontend; frontend is unconditionally imported at
# module level in `bootstrap.py` and its requirements thus need to be in
# requirements.txt to ensure they are always installed
"aiogithubapi==24.6.0",
"aiogithubapi==26.0.0",
# Integrations may depend on hassio integration without listing it to
# change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11
@@ -82,7 +82,7 @@ dependencies = [
"voluptuous==0.15.2",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.2.0",
"yarl==1.22.0",
"yarl==1.23.0",
"webrtc-models==0.3.0",
"zeroconf==0.148.0",
]

4
requirements.txt generated
View File

@@ -4,7 +4,7 @@
# Home Assistant Core
aiodns==4.0.0
aiogithubapi==24.6.0
aiogithubapi==26.0.0
aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
@@ -60,5 +60,5 @@ voluptuous-openapi==0.2.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
webrtc-models==0.3.0
yarl==1.22.0
yarl==1.23.0
zeroconf==0.148.0

14
requirements_all.txt generated
View File

@@ -56,7 +56,7 @@ PyFlume==0.6.5
PyFronius==0.8.0
# homeassistant.components.pyload
PyLoadAPI==1.4.2
PyLoadAPI==2.0.0
# homeassistant.components.met_eireann
PyMetEireann==2024.11.0
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==12.0.0
aioamazondevices==12.0.2
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -267,7 +267,7 @@ aioftp==0.21.3
aioghost==0.4.0
# homeassistant.components.github
aiogithubapi==24.6.0
aiogithubapi==26.0.0
# homeassistant.components.guardian
aioguardian==2026.01.1
@@ -443,7 +443,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webdav
aiowebdav2==0.5.0
aiowebdav2==0.6.1
# homeassistant.components.webostv
aiowebostv==0.7.5
@@ -828,7 +828,7 @@ dremel3dpy==2.1.1
dropmqttapi==1.0.3
# homeassistant.components.dsmr
dsmr-parser==1.4.3
dsmr-parser==1.5.0
# homeassistant.components.dwd_weather_warnings
dwdwfsapi==1.0.7
@@ -2551,7 +2551,7 @@ python-etherscan-api==0.0.3
python-family-hub-local==0.0.2
# homeassistant.components.fully_kiosk
python-fullykiosk==0.0.14
python-fullykiosk==0.0.15
# homeassistant.components.gc100
python-gc100==1.0.3a0
@@ -3307,7 +3307,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
yalexs-ble==3.2.4
yalexs-ble==3.2.7
# homeassistant.components.august
# homeassistant.components.yale

View File

@@ -56,7 +56,7 @@ PyFlume==0.6.5
PyFronius==0.8.0
# homeassistant.components.pyload
PyLoadAPI==1.4.2
PyLoadAPI==2.0.0
# homeassistant.components.met_eireann
PyMetEireann==2024.11.0
@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==12.0.0
aioamazondevices==12.0.2
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -255,7 +255,7 @@ aioflo==2021.11.0
aioghost==0.4.0
# homeassistant.components.github
aiogithubapi==24.6.0
aiogithubapi==26.0.0
# homeassistant.components.guardian
aioguardian==2026.01.1
@@ -428,7 +428,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webdav
aiowebdav2==0.5.0
aiowebdav2==0.6.1
# homeassistant.components.webostv
aiowebostv==0.7.5
@@ -734,7 +734,7 @@ dremel3dpy==2.1.1
dropmqttapi==1.0.3
# homeassistant.components.dsmr
dsmr-parser==1.4.3
dsmr-parser==1.5.0
# homeassistant.components.dwd_weather_warnings
dwdwfsapi==1.0.7
@@ -2162,7 +2162,7 @@ python-bsblan==5.1.0
python-ecobee-api==0.3.2
# homeassistant.components.fully_kiosk
python-fullykiosk==0.0.14
python-fullykiosk==0.0.15
# homeassistant.components.google_drive
python-google-drive-api==0.1.0
@@ -2786,7 +2786,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
yalexs-ble==3.2.4
yalexs-ble==3.2.7
# homeassistant.components.august
# homeassistant.components.yale

View File

@@ -149,7 +149,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
},
"flux_led": {"flux-led": {"async-timeout"}},
"foobot": {"foobot-async": {"async-timeout"}},
"github": {"aiogithubapi": {"async-timeout"}},
"harmony": {"aioharmony": {"async-timeout"}},
"here_travel_time": {
"here-routing": {"async-timeout"},

View File

@@ -4,6 +4,7 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, call
from chip.clusters import Objects as clusters
from chip.clusters.Objects import NullValue
from matter_server.client.models.node import MatterNode
from matter_server.common.errors import MatterError
from matter_server.common.models import EventType, MatterNodeEvent
@@ -37,10 +38,12 @@ from .common import (
# Feature map bits
_FEATURE_PIN = 1 # kPinCredential (bit 0)
_FEATURE_RFID = 2 # kRfidCredential (bit 1)
_FEATURE_FINGER = 4 # kFingerCredentials (bit 2)
_FEATURE_USR = 256 # kUser (bit 8)
_FEATURE_USR_PIN = _FEATURE_USR | _FEATURE_PIN # 257
_FEATURE_USR_RFID = _FEATURE_USR | _FEATURE_RFID # 258
_FEATURE_USR_PIN_RFID = _FEATURE_USR | _FEATURE_PIN | _FEATURE_RFID # 259
_FEATURE_USR_FINGER = _FEATURE_USR | _FEATURE_FINGER # 260
@pytest.mark.usefixtures("matter_devices")
@@ -516,6 +519,47 @@ async def test_clear_lock_user_service(
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_clear_lock_user_credentials_nullvalue(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test clear_lock_user handles NullValue credentials from Matter SDK."""
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetUser returns NullValue for credentials (truthy but not iterable)
{"userStatus": 1, "credentials": NullValue},
None, # ClearUser
]
)
await hass.services.async_call(
DOMAIN,
"clear_lock_user",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_USER_INDEX: 1,
},
blocking=True,
)
# GetUser + ClearUser (no ClearCredential since NullValue means no credentials)
assert matter_client.send_device_command.call_count == 2
assert matter_client.send_device_command.call_args_list[0] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetUser(userIndex=1),
)
assert matter_client.send_device_command.call_args_list[1] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.ClearUser(userIndex=1),
timed_request_timeout_ms=10000,
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_clear_lock_user_clears_credentials_first(
@@ -975,6 +1019,53 @@ async def test_get_lock_users_with_credentials(
}
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
async def test_get_lock_users_with_nullvalue_credentials(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test get_lock_users handles NullValue credentials from Matter SDK."""
matter_client.send_device_command = AsyncMock(
side_effect=[
{
"userIndex": 1,
"userStatus": 1,
"userName": "User No Creds",
"userUniqueID": 100,
"userType": 0,
"credentialRule": 0,
"credentials": NullValue,
"nextUserIndex": None,
},
]
)
result = await hass.services.async_call(
DOMAIN,
"get_lock_users",
{ATTR_ENTITY_ID: "lock.mock_door_lock"},
blocking=True,
return_response=True,
)
assert matter_client.send_device_command.call_count == 1
assert matter_client.send_device_command.call_args == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetUser(userIndex=1),
)
lock_users = result["lock.mock_door_lock"]
assert len(lock_users["users"]) == 1
user = lock_users["users"][0]
assert user["user_index"] == 1
assert user["user_name"] == "User No Creds"
assert user["user_unique_id"] == 100
assert user["credentials"] == []
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
@pytest.mark.parametrize(
@@ -1116,6 +1207,8 @@ async def test_set_lock_credential_pin(
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/18": 3, # NumberOfPINUsersSupported
"1/257/28": 2, # NumberOfCredentialsSupportedPerUser (must NOT be used)
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
@@ -1126,19 +1219,24 @@ async def test_set_lock_credential_auto_find_slot(
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential auto-finds first available slot."""
"""Test set_lock_credential auto-finds first available PIN slot."""
# Place the empty slot at index 3 (the last position within
# NumberOfPINUsersSupported=3) so the test would fail if the code
# used NumberOfCredentialsSupportedPerUser=2 instead.
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus(1): occupied
{"credentialExists": True, "userIndex": 1, "nextCredentialIndex": 2},
# GetCredentialStatus(2): empty
# GetCredentialStatus(2): occupied
{"credentialExists": True, "userIndex": 2, "nextCredentialIndex": 3},
# GetCredentialStatus(3): empty — found at the bound limit
{
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": 3,
"nextCredentialIndex": None,
},
# SetCredential response
{"status": 0, "userIndex": 1, "nextCredentialIndex": 3},
{"status": 0, "userIndex": 1, "nextCredentialIndex": None},
]
)
@@ -1155,19 +1253,20 @@ async def test_set_lock_credential_auto_find_slot(
)
assert result["lock.mock_door_lock"] == {
"credential_index": 2,
"credential_index": 3,
"user_index": 1,
"next_credential_index": 3,
"next_credential_index": None,
}
assert matter_client.send_device_command.call_count == 3
# Verify SetCredential was called with kAdd for the empty slot at index 2
set_cred_cmd = matter_client.send_device_command.call_args_list[2]
# 3 GetCredentialStatus calls + 1 SetCredential = 4 total
assert matter_client.send_device_command.call_count == 4
# Verify SetCredential was called with kAdd for the empty slot at index 3
set_cred_cmd = matter_client.send_device_command.call_args_list[3]
assert (
set_cred_cmd.kwargs["command"].operationType
== clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd
)
assert set_cred_cmd.kwargs["command"].credential.credentialIndex == 2
assert set_cred_cmd.kwargs["command"].credential.credentialIndex == 3
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@@ -1364,6 +1463,8 @@ async def test_set_lock_credential_status_failure(
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/18": 3, # NumberOfPINUsersSupported
"1/257/28": 5, # NumberOfCredentialsSupportedPerUser (should NOT be used)
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
@@ -1397,6 +1498,78 @@ async def test_set_lock_credential_no_available_slot(
return_response=True,
)
# Verify it iterated over NumberOfPINUsersSupported (3), not
# NumberOfCredentialsSupportedPerUser (5)
assert matter_client.send_device_command.call_count == 3
pin_type = clusters.DoorLock.Enums.CredentialTypeEnum.kPin
for idx in range(3):
assert matter_client.send_device_command.call_args_list[idx] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=pin_type,
credentialIndex=idx + 1,
),
),
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_PIN,
"1/257/18": None, # NumberOfPINUsersSupported not available
"1/257/24": 4, # MinPINCodeLength
"1/257/23": 8, # MaxPINCodeLength
}
],
)
async def test_set_lock_credential_auto_find_defaults_to_five(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential falls back to 5 slots when capacity attribute is None."""
# All GetCredentialStatus calls return occupied
matter_client.send_device_command = AsyncMock(
return_value={
"credentialExists": True,
"userIndex": 1,
"nextCredentialIndex": None,
}
)
with pytest.raises(ServiceValidationError, match="No available credential slots"):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "pin",
ATTR_CREDENTIAL_DATA: "1234",
},
blocking=True,
return_response=True,
)
# With NumberOfPINUsersSupported=None, falls back to default of 5
assert matter_client.send_device_command.call_count == 5
pin_type = clusters.DoorLock.Enums.CredentialTypeEnum.kPin
for idx in range(5):
assert matter_client.send_device_command.call_args_list[idx] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=pin_type,
credentialIndex=idx + 1,
),
),
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}])
@@ -1646,6 +1819,207 @@ async def test_set_lock_credential_rfid(
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_RFID,
"1/257/19": 3, # NumberOfRFIDUsersSupported
"1/257/28": 2, # NumberOfCredentialsSupportedPerUser (must NOT be used)
"1/257/26": 4, # MinRFIDCodeLength
"1/257/25": 20, # MaxRFIDCodeLength
}
],
)
async def test_set_lock_credential_rfid_auto_find_slot(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential auto-finds RFID slot using NumberOfRFIDUsersSupported."""
# Place the empty slot at index 3 (the last position within
# NumberOfRFIDUsersSupported=3) so the test would fail if the code
# used a smaller bound like NumberOfCredentialsSupportedPerUser=2
# or stopped iterating too early.
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus(1): occupied
{"credentialExists": True, "userIndex": 1, "nextCredentialIndex": 2},
# GetCredentialStatus(2): occupied
{"credentialExists": True, "userIndex": 2, "nextCredentialIndex": 3},
# GetCredentialStatus(3): empty — found at the bound limit
{
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": None,
},
# SetCredential response
{"status": 0, "userIndex": 1, "nextCredentialIndex": None},
]
)
result = await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "rfid",
ATTR_CREDENTIAL_DATA: "AABBCCDD",
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"credential_index": 3,
"user_index": 1,
"next_credential_index": None,
}
# 3 GetCredentialStatus calls + 1 SetCredential = 4 total
assert matter_client.send_device_command.call_count == 4
# Verify SetCredential was called with kAdd for the empty slot at index 3
set_cred_cmd = matter_client.send_device_command.call_args_list[3]
assert (
set_cred_cmd.kwargs["command"].operationType
== clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd
)
assert set_cred_cmd.kwargs["command"].credential.credentialIndex == 3
assert (
set_cred_cmd.kwargs["command"].credential.credentialType
== clusters.DoorLock.Enums.CredentialTypeEnum.kRfid
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_RFID,
"1/257/19": 3, # NumberOfRFIDUsersSupported
"1/257/28": 5, # NumberOfCredentialsSupportedPerUser (should NOT be used)
"1/257/26": 4, # MinRFIDCodeLength
"1/257/25": 20, # MaxRFIDCodeLength
}
],
)
async def test_set_lock_credential_rfid_no_available_slot(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential RFID raises error when all slots are full."""
matter_client.send_device_command = AsyncMock(
return_value={
"credentialExists": True,
"userIndex": 1,
"nextCredentialIndex": None,
}
)
with pytest.raises(ServiceValidationError, match="No available credential slots"):
await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "rfid",
ATTR_CREDENTIAL_DATA: "AABBCCDD",
},
blocking=True,
return_response=True,
)
# Verify it iterated over NumberOfRFIDUsersSupported (3), not
# NumberOfCredentialsSupportedPerUser (5)
assert matter_client.send_device_command.call_count == 3
rfid_type = clusters.DoorLock.Enums.CredentialTypeEnum.kRfid
for idx in range(3):
assert matter_client.send_device_command.call_args_list[idx] == call(
node_id=matter_node.node_id,
endpoint_id=1,
command=clusters.DoorLock.Commands.GetCredentialStatus(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=rfid_type,
credentialIndex=idx + 1,
),
),
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",
[
{
"1/257/65532": _FEATURE_USR_FINGER,
"1/257/17": 3, # NumberOfTotalUsersSupported (fallback for biometrics)
"1/257/18": 10, # NumberOfPINUsersSupported (should NOT be used)
"1/257/28": 2, # NumberOfCredentialsSupportedPerUser (should NOT be used)
}
],
)
async def test_set_lock_credential_fingerprint_auto_find_slot(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test set_lock_credential auto-finds fingerprint slot using NumberOfTotalUsersSupported."""
# Place the empty slot at index 3 (the last position within
# NumberOfTotalUsersSupported=3) so the test would fail if the code
# used NumberOfPINUsersSupported (10) or NumberOfCredentialsSupportedPerUser (2).
matter_client.send_device_command = AsyncMock(
side_effect=[
# GetCredentialStatus(1): occupied
{"credentialExists": True, "userIndex": 1, "nextCredentialIndex": 2},
# GetCredentialStatus(2): occupied
{"credentialExists": True, "userIndex": 2, "nextCredentialIndex": 3},
# GetCredentialStatus(3): empty — found at the bound limit
{
"credentialExists": False,
"userIndex": None,
"nextCredentialIndex": None,
},
# SetCredential response
{"status": 0, "userIndex": 1, "nextCredentialIndex": None},
]
)
result = await hass.services.async_call(
DOMAIN,
"set_lock_credential",
{
ATTR_ENTITY_ID: "lock.mock_door_lock",
ATTR_CREDENTIAL_TYPE: "fingerprint",
ATTR_CREDENTIAL_DATA: "AABBCCDD",
},
blocking=True,
return_response=True,
)
assert result["lock.mock_door_lock"] == {
"credential_index": 3,
"user_index": 1,
"next_credential_index": None,
}
# 3 GetCredentialStatus calls + 1 SetCredential = 4 total
assert matter_client.send_device_command.call_count == 4
# Verify SetCredential was called with kAdd for the empty slot at index 3
set_cred_cmd = matter_client.send_device_command.call_args_list[3]
assert (
set_cred_cmd.kwargs["command"].operationType
== clusters.DoorLock.Enums.DataOperationTypeEnum.kAdd
)
assert set_cred_cmd.kwargs["command"].credential.credentialIndex == 3
assert (
set_cred_cmd.kwargs["command"].credential.credentialType
== clusters.DoorLock.Enums.CredentialTypeEnum.kFingerprint
)
@pytest.mark.parametrize("node_fixture", ["mock_door_lock"])
@pytest.mark.parametrize(
"attributes",

View File

@@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch
from aiomodernforms import ModernFormsConnectionError
from homeassistant.components.modern_forms.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -31,11 +30,11 @@ async def test_unload_config_entry(
) -> None:
"""Test the Modern Forms configuration entry unloading."""
entry = await init_integration(hass, aioclient_mock)
assert hass.data[DOMAIN]
assert entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert not hass.data.get(DOMAIN)
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_fan_only_device(

View File

@@ -84,7 +84,7 @@ async def test_service_get_requests_no_meta(
"get_requests",
OverseerrConnectionError("Timeout"),
HomeAssistantError,
"Error connecting to the Overseerr instance: Timeout",
"Error connecting to the Seerr instance: Timeout",
)
],
)

View File

@@ -3,7 +3,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from pyloadapi.types import LoginResponse, StatusServerResponse
from pyloadapi.types import StatusServerResponse
import pytest
from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN
@@ -76,18 +76,6 @@ def mock_pyloadapi() -> Generator[MagicMock]:
client = mock_client.return_value
client.username = "username"
client.api_url = "https://pyload.local:8000/"
client.login.return_value = LoginResponse(
{
"_permanent": True,
"authenticated": True,
"id": 2,
"name": "username",
"role": 0,
"perms": 0,
"template": "default",
"_flashes": [["message", "Logged in successfully"]],
}
)
client.get_status.return_value = StatusServerResponse(
{

View File

@@ -1,823 +1,4 @@
# serializer version: 1
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_active_downloads-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_active_downloads',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Active downloads',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Active downloads',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.ACTIVE: 'active'>,
'unique_id': 'XXXXXXXXXXXXXX_active',
'unit_of_measurement': 'downloads',
})
# ---
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_active_downloads-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'pyLoad Active downloads',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'downloads',
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_active_downloads',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_downloads_in_queue-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_downloads_in_queue',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Downloads in queue',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Downloads in queue',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.QUEUE: 'queue'>,
'unique_id': 'XXXXXXXXXXXXXX_queue',
'unit_of_measurement': 'downloads',
})
# ---
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_downloads_in_queue-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'pyLoad Downloads in queue',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'downloads',
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_downloads_in_queue',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_free_space-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_free_space',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Free space',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
'original_icon': None,
'original_name': 'Free space',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.FREE_SPACE: 'free_space'>,
'unique_id': 'XXXXXXXXXXXXXX_free_space',
'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
})
# ---
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_free_space-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_size',
'friendly_name': 'pyLoad Free space',
'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_free_space',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Speed',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'Speed',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.SPEED: 'speed'>,
'unique_id': 'XXXXXXXXXXXXXX_speed',
'unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
})
# ---
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyLoad Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_total_downloads-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_total_downloads',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Total downloads',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Total downloads',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.TOTAL: 'total'>,
'unique_id': 'XXXXXXXXXXXXXX_total',
'unit_of_measurement': 'downloads',
})
# ---
# name: test_sensor_update_exceptions[CannotConnect][sensor.pyload_total_downloads-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'pyLoad Total downloads',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'downloads',
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_total_downloads',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_active_downloads-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_active_downloads',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Active downloads',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Active downloads',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.ACTIVE: 'active'>,
'unique_id': 'XXXXXXXXXXXXXX_active',
'unit_of_measurement': 'downloads',
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_active_downloads-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'pyLoad Active downloads',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'downloads',
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_active_downloads',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_downloads_in_queue-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_downloads_in_queue',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Downloads in queue',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Downloads in queue',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.QUEUE: 'queue'>,
'unique_id': 'XXXXXXXXXXXXXX_queue',
'unit_of_measurement': 'downloads',
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_downloads_in_queue-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'pyLoad Downloads in queue',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'downloads',
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_downloads_in_queue',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '6',
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_free_space-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_free_space',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Free space',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
'original_icon': None,
'original_name': 'Free space',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.FREE_SPACE: 'free_space'>,
'unique_id': 'XXXXXXXXXXXXXX_free_space',
'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_free_space-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_size',
'friendly_name': 'pyLoad Free space',
'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_free_space',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '93.1322574606165',
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Speed',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'Speed',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.SPEED: 'speed'>,
'unique_id': 'XXXXXXXXXXXXXX_speed',
'unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyLoad Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '43.247704',
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_total_downloads-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_total_downloads',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Total downloads',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Total downloads',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.TOTAL: 'total'>,
'unique_id': 'XXXXXXXXXXXXXX_total',
'unit_of_measurement': 'downloads',
})
# ---
# name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_total_downloads-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'pyLoad Total downloads',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'downloads',
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_total_downloads',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '37',
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_active_downloads-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_active_downloads',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Active downloads',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Active downloads',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.ACTIVE: 'active'>,
'unique_id': 'XXXXXXXXXXXXXX_active',
'unit_of_measurement': 'downloads',
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_active_downloads-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'pyLoad Active downloads',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'downloads',
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_active_downloads',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_downloads_in_queue-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_downloads_in_queue',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Downloads in queue',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Downloads in queue',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.QUEUE: 'queue'>,
'unique_id': 'XXXXXXXXXXXXXX_queue',
'unit_of_measurement': 'downloads',
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_downloads_in_queue-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'pyLoad Downloads in queue',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'downloads',
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_downloads_in_queue',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_free_space-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_free_space',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Free space',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
'original_icon': None,
'original_name': 'Free space',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.FREE_SPACE: 'free_space'>,
'unique_id': 'XXXXXXXXXXXXXX_free_space',
'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_free_space-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_size',
'friendly_name': 'pyLoad Free space',
'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_free_space',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Speed',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'Speed',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.SPEED: 'speed'>,
'unique_id': 'XXXXXXXXXXXXXX_speed',
'unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'pyLoad Speed',
'unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_total_downloads-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pyload_total_downloads',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Total downloads',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Total downloads',
'platform': 'pyload',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <PyLoadSensorEntity.TOTAL: 'total'>,
'unique_id': 'XXXXXXXXXXXXXX_total',
'unit_of_measurement': 'downloads',
})
# ---
# name: test_sensor_update_exceptions[ParserError][sensor.pyload_total_downloads-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'pyLoad Total downloads',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'downloads',
}),
'context': <ANY>,
'entity_id': 'sensor.pyload_total_downloads',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_setup[sensor.pyload_active_downloads-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -65,7 +65,7 @@ async def test_form_errors(
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_pyloadapi.login.side_effect = exception
mock_pyloadapi.get_status.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -75,7 +75,7 @@ async def test_form_errors(
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
mock_pyloadapi.login.side_effect = None
mock_pyloadapi.get_status.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
@@ -159,7 +159,7 @@ async def test_reauth_errors(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
mock_pyloadapi.login.side_effect = side_effect
mock_pyloadapi.get_status.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
REAUTH_INPUT,
@@ -168,7 +168,7 @@ async def test_reauth_errors(
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_text}
mock_pyloadapi.login.side_effect = None
mock_pyloadapi.get_status.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
REAUTH_INPUT,
@@ -231,7 +231,7 @@ async def test_reconfigure_errors(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
mock_pyloadapi.login.side_effect = side_effect
mock_pyloadapi.get_status.side_effect = side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
@@ -240,7 +240,7 @@ async def test_reconfigure_errors(
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_text}
mock_pyloadapi.login.side_effect = None
mock_pyloadapi.get_status.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
@@ -261,7 +261,7 @@ async def test_hassio_discovery(
) -> None:
"""Test flow started from Supervisor discovery."""
mock_pyloadapi.login.side_effect = InvalidAuth
mock_pyloadapi.get_status.side_effect = InvalidAuth
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -273,7 +273,7 @@ async def test_hassio_discovery(
assert result["step_id"] == "hassio_confirm"
assert result["errors"] is None
mock_pyloadapi.login.side_effect = None
mock_pyloadapi.get_status.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"}
@@ -325,7 +325,7 @@ async def test_hassio_discovery_errors(
) -> None:
"""Test flow started from Supervisor discovery."""
mock_pyloadapi.login.side_effect = side_effect
mock_pyloadapi.get_status.side_effect = side_effect
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -344,7 +344,7 @@ async def test_hassio_discovery_errors(
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_text}
mock_pyloadapi.login.side_effect = None
mock_pyloadapi.get_status.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"}

View File

@@ -1,9 +1,7 @@
"""Test pyLoad init."""
from datetime import timedelta
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
import pytest
@@ -11,7 +9,7 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_PATH, CONF_URL
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.common import MockConfigEntry
async def test_entry_setup_unload(
@@ -44,7 +42,7 @@ async def test_config_entry_setup_errors(
side_effect: Exception,
) -> None:
"""Test config entry not ready."""
mock_pyloadapi.login.side_effect = side_effect
mock_pyloadapi.version.side_effect = side_effect
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -58,7 +56,7 @@ async def test_config_entry_setup_invalid_auth(
mock_pyloadapi: MagicMock,
) -> None:
"""Test config entry authentication."""
mock_pyloadapi.login.side_effect = InvalidAuth
mock_pyloadapi.version.side_effect = InvalidAuth
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -72,25 +70,61 @@ async def test_coordinator_update_invalid_auth(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pyloadapi: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test coordinator authentication."""
mock_pyloadapi.get_status.side_effect = InvalidAuth
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
mock_pyloadapi.login.side_effect = InvalidAuth
mock_pyloadapi.get_status.side_effect = InvalidAuth
freezer.tick(timedelta(seconds=20))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH}))
async def test_coordinator_setup_invalid_auth(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pyloadapi: MagicMock,
) -> None:
"""Test coordinator setup authentication."""
mock_pyloadapi.version.side_effect = InvalidAuth
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH}))
@pytest.mark.parametrize(
("exception", "state"),
[
(CannotConnect, ConfigEntryState.SETUP_RETRY),
(InvalidAuth, ConfigEntryState.SETUP_ERROR),
(ParserError, ConfigEntryState.SETUP_RETRY),
],
)
async def test_coordinator_update_errors(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pyloadapi: MagicMock,
exception: Exception,
state: ConfigEntryState,
) -> None:
"""Test coordinator setup authentication."""
mock_pyloadapi.get_status.side_effect = exception
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is state
@pytest.mark.usefixtures("mock_pyloadapi")
async def test_migration(
hass: HomeAssistant,

View File

@@ -3,18 +3,15 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.pyload.coordinator import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
@@ -42,78 +39,3 @@ async def test_setup(
assert config_entry.state is ConfigEntryState.LOADED
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.parametrize(
"exception",
[CannotConnect, InvalidAuth, ParserError],
)
async def test_sensor_update_exceptions(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pyloadapi: AsyncMock,
exception: Exception,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test if pyLoad sensors go unavailable when exceptions occur (except ParserErrors)."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
mock_pyloadapi.get_status.side_effect = exception
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
async def test_sensor_invalid_auth(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pyloadapi: AsyncMock,
caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test invalid auth during sensor update."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
mock_pyloadapi.get_status.side_effect = InvalidAuth
mock_pyloadapi.login.side_effect = InvalidAuth
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
"Authentication failed for username, verify your login credentials"
in caplog.text
)
async def test_pyload_pre_0_5_0(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pyloadapi: AsyncMock,
) -> None:
"""Test setup of the pyload sensor platform."""
mock_pyloadapi.get_status.return_value = {
"pause": False,
"active": 1,
"queue": 6,
"total": 37,
"speed": 5405963.0,
"download": True,
"reconnect": False,
}
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED

View File

@@ -1836,6 +1836,55 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_rvc_map_01011][binary_sensor.robot_vacuum_dust_bag_full-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.robot_vacuum_dust_bag_full',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Dust bag full',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Dust bag full',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'robot_cleaner_dust_bag',
'unique_id': '01b28624-5907-c8bc-0325-8ad23f03a637_station_samsungce.robotCleanerDustBag_status_status',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_rvc_map_01011][binary_sensor.robot_vacuum_dust_bag_full-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Robot Vacuum Dust bag full',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.robot_vacuum_dust_bag_full',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -533,6 +533,130 @@
'state': 'on',
})
# ---
# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_sound_detection_sensitivity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'low',
'medium',
'high',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.robot_vacuum_sound_detection_sensitivity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Sound detection sensitivity',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Sound detection sensitivity',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'sound_detection_sensitivity',
'unique_id': '01b28624-5907-c8bc-0325-8ad23f03a637_main_samsungce.soundDetectionSensitivity_level_level',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_sound_detection_sensitivity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Robot Vacuum Sound detection sensitivity',
'options': list([
'low',
'medium',
'high',
]),
}),
'context': <ANY>,
'entity_id': 'select.robot_vacuum_sound_detection_sensitivity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'medium',
})
# ---
# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_water_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'high',
'moderate_high',
'medium',
'moderate_low',
'low',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.robot_vacuum_water_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Water level',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Water level',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'robot_cleaner_water_spray_level',
'unique_id': '01b28624-5907-c8bc-0325-8ad23f03a637_main_samsungce.robotCleanerWaterSprayLevel_waterSprayLevel_waterSprayLevel',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_water_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Robot Vacuum Water level',
'options': list([
'high',
'moderate_high',
'medium',
'moderate_low',
'low',
]),
}),
'context': <ANY>,
'entity_id': 'select.robot_vacuum_water_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'medium',
})
# ---
# name: test_all_entities[da_wm_dw_000001][select.dishwasher-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -11504,7 +11504,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.015',
'state': '15.0',
})
# ---
# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power_energy-entry]
@@ -11850,7 +11850,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.015',
'state': '15.0',
})
# ---
# name: test_all_entities[da_sac_ehs_000001_sub_1][sensor.heat_pump_main_power_energy-entry]
@@ -12196,7 +12196,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.015',
'state': '15.0',
})
# ---
# name: test_all_entities[da_sac_ehs_000002_sub][sensor.warmepumpe_power_energy-entry]

View File

@@ -0,0 +1,197 @@
# serializer version: 1
# name: test_all_entities[da_ks_hood_01001][time.range_hood_do_not_disturb_end_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.range_hood_do_not_disturb_end_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Do not disturb end time',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Do not disturb end time',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'do_not_disturb_end_time',
'unique_id': 'fa5fca25-fa7a-1807-030a-2f72ee0f7bff_main_custom.doNotDisturbMode_endTime_endTime',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_hood_01001][time.range_hood_do_not_disturb_end_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Range hood Do not disturb end time',
}),
'context': <ANY>,
'entity_id': 'time.range_hood_do_not_disturb_end_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '00:00:00',
})
# ---
# name: test_all_entities[da_ks_hood_01001][time.range_hood_do_not_disturb_start_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.range_hood_do_not_disturb_start_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Do not disturb start time',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Do not disturb start time',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'do_not_disturb_start_time',
'unique_id': 'fa5fca25-fa7a-1807-030a-2f72ee0f7bff_main_custom.doNotDisturbMode_startTime_startTime',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_hood_01001][time.range_hood_do_not_disturb_start_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Range hood Do not disturb start time',
}),
'context': <ANY>,
'entity_id': 'time.range_hood_do_not_disturb_start_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '00:00:00',
})
# ---
# name: test_all_entities[da_rvc_map_01011][time.robot_vacuum_do_not_disturb_end_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.robot_vacuum_do_not_disturb_end_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Do not disturb end time',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Do not disturb end time',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'do_not_disturb_end_time',
'unique_id': '01b28624-5907-c8bc-0325-8ad23f03a637_main_custom.doNotDisturbMode_endTime_endTime',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_rvc_map_01011][time.robot_vacuum_do_not_disturb_end_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Robot Vacuum Do not disturb end time',
}),
'context': <ANY>,
'entity_id': 'time.robot_vacuum_do_not_disturb_end_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '06:00:00',
})
# ---
# name: test_all_entities[da_rvc_map_01011][time.robot_vacuum_do_not_disturb_start_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.robot_vacuum_do_not_disturb_start_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Do not disturb start time',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Do not disturb start time',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'do_not_disturb_start_time',
'unique_id': '01b28624-5907-c8bc-0325-8ad23f03a637_main_custom.doNotDisturbMode_startTime_startTime',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_rvc_map_01011][time.robot_vacuum_do_not_disturb_start_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Robot Vacuum Do not disturb start time',
}),
'context': <ANY>,
'entity_id': 'time.robot_vacuum_do_not_disturb_start_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '22:00:00',
})
# ---

View File

@@ -30,6 +30,7 @@ from . import (
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,

View File

@@ -0,0 +1,128 @@
"""Test for the SmartThings time platform."""
from unittest.mock import AsyncMock
from pysmartthings import Attribute, Capability, Command
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.smartthings import MAIN
from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration, snapshot_smartthings_entities, trigger_update
from tests.common import MockConfigEntry
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
await setup_integration(hass, mock_config_entry)
snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.TIME)
@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"])
async def test_state_update(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test state update."""
await setup_integration(hass, mock_config_entry)
assert (
hass.states.get("time.robot_vacuum_do_not_disturb_end_time").state == "06:00:00"
)
await trigger_update(
hass,
devices,
"01b28624-5907-c8bc-0325-8ad23f03a637",
Capability.CUSTOM_DO_NOT_DISTURB_MODE,
Attribute.END_TIME,
"0800",
)
assert (
hass.states.get("time.robot_vacuum_do_not_disturb_end_time").state == "08:00:00"
)
@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"])
async def test_set_value(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting a value."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
TIME_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: "time.robot_vacuum_do_not_disturb_end_time",
ATTR_TIME: "09:00:00",
},
blocking=True,
)
devices.execute_device_command.assert_called_once_with(
"01b28624-5907-c8bc-0325-8ad23f03a637",
Capability.CUSTOM_DO_NOT_DISTURB_MODE,
Command.SET_DO_NOT_DISTURB_MODE,
MAIN,
argument={
"mode": "on",
"startTime": "2200",
"endTime": "0900",
},
)
@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"])
async def test_dnd_mode_updates(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting a value."""
await setup_integration(hass, mock_config_entry)
await trigger_update(
hass,
devices,
"01b28624-5907-c8bc-0325-8ad23f03a637",
Capability.CUSTOM_DO_NOT_DISTURB_MODE,
Attribute.DO_NOT_DISTURB,
"off",
)
await hass.services.async_call(
TIME_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: "time.robot_vacuum_do_not_disturb_end_time",
ATTR_TIME: "09:00:00",
},
blocking=True,
)
devices.execute_device_command.assert_called_once_with(
"01b28624-5907-c8bc-0325-8ad23f03a637",
Capability.CUSTOM_DO_NOT_DISTURB_MODE,
Command.SET_DO_NOT_DISTURB_MODE,
MAIN,
argument={
"mode": "off",
"startTime": "2200",
"endTime": "0900",
},
)

View File

@@ -42,6 +42,7 @@ async def _download_mock(path: str, timeout=None) -> AsyncIterator[bytes]:
"""Mock the download function."""
if path.endswith(".json"):
yield dumps(BACKUP_METADATA).encode()
return
yield b"backup data"

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, AsyncIterator
from io import StringIO
from unittest.mock import Mock, patch
@@ -13,6 +13,7 @@ from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup
from homeassistant.components.webdav.backup import async_register_backup_agents_listener
from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import json_dumps
from homeassistant.setup import async_setup_component
from .const import BACKUP_METADATA
@@ -324,3 +325,44 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None:
remove_listener()
assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None
async def test_agents_list_backups_with_multi_chunk_metadata(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
webdav_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test listing backups when metadata is returned in multiple chunks."""
metadata_json = json_dumps(BACKUP_METADATA).encode()
mid = len(metadata_json) // 2
chunk1 = metadata_json[:mid]
chunk2 = metadata_json[mid:]
async def _multi_chunk_download(path: str, timeout=None) -> AsyncIterator[bytes]:
"""Mock download returning metadata in multiple chunks."""
if path.endswith(".json"):
yield chunk1
yield chunk2
return
yield b"backup data"
webdav_client.download_iter.side_effect = _multi_chunk_download
# Invalidate the metadata cache so the new mock is used
hass.config_entries.async_update_entry(
mock_config_entry, title=mock_config_entry.title
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "backup/info"})
response = await client.receive_json()
assert response["success"]
assert response["result"]["agent_errors"] == {}
backups = response["result"]["backups"]
assert len(backups) == 1
assert backups[0]["backup_id"] == BACKUP_METADATA["backup_id"]
assert backups[0]["name"] == BACKUP_METADATA["name"]