diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index b0e1540bc92d..32b95ba00c31 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -1,10 +1,13 @@ """The Rituals Perfume Genie integration.""" import asyncio -import logging -from aiohttp import ClientError, ClientResponseError -from pyrituals import Account, AuthenticationException, Diffuser +from ritualsgenie import ( + RitualsGenie, + RitualsGenieAuthenticationError, + RitualsGenieError, + RitualsGenieHub, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform @@ -16,8 +19,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ACCOUNT_HASH, UPDATE_INTERVAL from .coordinator import RitualsConfigEntry, RitualsDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.NUMBER, @@ -34,50 +35,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: RitualsConfigEntry) -> b if CONF_EMAIL not in entry.data or CONF_PASSWORD not in entry.data: raise ConfigEntryAuthFailed("Missing credentials") - session = async_get_clientsession(hass) - - account = Account( + client = RitualsGenie( email=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], - session=session, + session=async_get_clientsession(hass), ) try: - # Authenticate first so API token/cookies are available for subsequent calls - await account.authenticate() - account_devices = await account.get_devices() - - except AuthenticationException as err: - # Credentials invalid/expired -> raise AuthFailed to trigger reauth flow - + hubs = await client.hubs() + except RitualsGenieAuthenticationError as err: raise ConfigEntryAuthFailed(err) from err - - except ClientResponseError as err: - _LOGGER.debug( - "HTTP error during Rituals setup: status=%s, url=%s, headers=%s", - err.status, - err.request_info, - dict(err.headers or {}), - ) - raise ConfigEntryNotReady from err - - except ClientError as err: - raise ConfigEntryNotReady from err + except RitualsGenieError as err: + raise ConfigEntryNotReady(err) from err # Migrate old unique_ids to the new format - async_migrate_entities_unique_ids(hass, entry, account_devices) + async_migrate_entities_unique_ids(hass, entry, hubs) # The API provided by Rituals is currently rate limited to 30 requests # per hour per IP address. To avoid hitting this limit, we will adjust # the polling interval based on the number of diffusers one has. - update_interval = UPDATE_INTERVAL * len(account_devices) + update_interval = UPDATE_INTERVAL * len(hubs) # Create a coordinator for each diffuser coordinators = { - diffuser.hublot: RitualsDataUpdateCoordinator( - hass, entry, account, diffuser, update_interval + hub.hublot: RitualsDataUpdateCoordinator( + hass, entry, client, hub, update_interval ) - for diffuser in account_devices + for hub in hubs } # Refresh all coordinators @@ -101,7 +85,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: RitualsConfigEntry) -> @callback def async_migrate_entities_unique_ids( - hass: HomeAssistant, config_entry: ConfigEntry, diffusers: list[Diffuser] + hass: HomeAssistant, config_entry: ConfigEntry, hubs: list[RitualsGenieHub] ) -> None: """Migrate unique_ids in the entity registry to the new format.""" entity_registry = er.async_get(hass) @@ -120,17 +104,17 @@ def async_migrate_entities_unique_ids( (Platform.SWITCH, ""): "is_on", } - for diffuser in diffusers: + for hub in hubs: for registry_entry in registry_entries: if new_unique_id := conversion.get( ( registry_entry.domain, - registry_entry.unique_id.removeprefix(diffuser.hublot), + registry_entry.unique_id.removeprefix(hub.hublot), ) ): entity_registry.async_update_entity( registry_entry.entity_id, - new_unique_id=f"{diffuser.hublot}-{new_unique_id}", + new_unique_id=f"{hub.hublot}-{new_unique_id}", ) diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 2867f4547472..3cbfa93d9049 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -4,8 +4,6 @@ from collections.abc import Callable from dataclasses import dataclass from typing import override -from pyrituals import Diffuser - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -15,7 +13,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import RitualsConfigEntry +from .coordinator import RitualsConfigEntry, RitualsData from .entity import DiffuserEntity PARALLEL_UPDATES = 0 @@ -25,8 +23,8 @@ PARALLEL_UPDATES = 0 class RitualsBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing Rituals binary sensor entities.""" - is_on_fn: Callable[[Diffuser], bool] - has_fn: Callable[[Diffuser], bool] + is_on_fn: Callable[[RitualsData], bool | None] + has_fn: Callable[[RitualsData], bool] ENTITY_DESCRIPTIONS = ( @@ -34,8 +32,8 @@ ENTITY_DESCRIPTIONS = ( key="charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda diffuser: diffuser.charging, - has_fn=lambda diffuser: diffuser.has_battery, + is_on_fn=lambda data: data.sensors.battery_charging, + has_fn=lambda data: data.hub.has_battery, ), ) @@ -52,7 +50,7 @@ async def async_setup_entry( RitualsBinarySensorEntity(coordinator, description) for coordinator in coordinators.values() for description in ENTITY_DESCRIPTIONS - if description.has_fn(coordinator.diffuser) + if description.has_fn(coordinator.data) ) @@ -63,6 +61,6 @@ class RitualsBinarySensorEntity(DiffuserEntity, BinarySensorEntity): @property @override - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the state of the binary sensor.""" - return self.entity_description.is_on_fn(self.coordinator.diffuser) + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/rituals_perfume_genie/config_flow.py b/homeassistant/components/rituals_perfume_genie/config_flow.py index 3b37af3331c4..deb7f5db1659 100644 --- a/homeassistant/components/rituals_perfume_genie/config_flow.py +++ b/homeassistant/components/rituals_perfume_genie/config_flow.py @@ -3,9 +3,12 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any, override -from aiohttp import ClientError import probatio -from pyrituals import Account, AuthenticationException +from ritualsgenie import ( + RitualsGenie, + RitualsGenieAuthenticationError, + RitualsGenieError, +) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_EMAIL, CONF_PASSWORD @@ -34,17 +37,17 @@ class RitualsPerfumeGenieConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: session = async_get_clientsession(self.hass) - account = Account( + client = RitualsGenie( email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD], session=session, ) try: - await account.authenticate() - except AuthenticationException: + await client.login() + except RitualsGenieAuthenticationError: errors["base"] = "invalid_auth" - except ClientError: + except RitualsGenieError: errors["base"] = "cannot_connect" else: await self.async_set_unique_id(user_input[CONF_EMAIL]) @@ -77,17 +80,17 @@ class RitualsPerfumeGenieConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: session = async_get_clientsession(self.hass) - account = Account( + client = RitualsGenie( email=reauth_entry.unique_id, password=user_input[CONF_PASSWORD], session=session, ) try: - await account.authenticate() - except AuthenticationException: + await client.login() + except RitualsGenieAuthenticationError: errors["base"] = "invalid_auth" - except ClientError: + except RitualsGenieError: errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort( diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py index f69244a8d691..6cd7ab49e91d 100644 --- a/homeassistant/components/rituals_perfume_genie/coordinator.py +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -1,11 +1,19 @@ """The Rituals Perfume Genie data update coordinator.""" +from dataclasses import dataclass from datetime import timedelta import logging from typing import override -from aiohttp import ClientError, ClientResponseError -from pyrituals import Account, AuthenticationException, Diffuser +from ritualsgenie import ( + RitualsGenie, + RitualsGenieAuthenticationError, + RitualsGenieError, + RitualsGenieHub, + RitualsGenieRateLimitError, + RitualsGenieSensors, + Sensor, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -16,10 +24,21 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +# Only the sensors the entities use; every sensor is a request of its own. +SENSORS = (Sensor.BATTERY, Sensor.FILL, Sensor.PERFUME, Sensor.WIFI) + type RitualsConfigEntry = ConfigEntry[dict[str, RitualsDataUpdateCoordinator]] -class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): +@dataclass +class RitualsData: + """State and sensor readings of a diffuser.""" + + hub: RitualsGenieHub + sensors: RitualsGenieSensors + + +class RitualsDataUpdateCoordinator(DataUpdateCoordinator[RitualsData]): """Manage fetching Rituals Perfume Genie device data.""" config_entry: RitualsConfigEntry @@ -27,56 +46,34 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, - account: Account, - diffuser: Diffuser, + config_entry: RitualsConfigEntry, + client: RitualsGenie, + hub: RitualsGenieHub, update_interval: timedelta, ) -> None: """Initialize global Rituals Perfume Genie data updater.""" - self.account = account - self.diffuser = diffuser + self.client = client + self.hub_hash = hub.hash + self.hublot = hub.hublot super().__init__( hass, _LOGGER, config_entry=config_entry, - name=f"{DOMAIN}-{diffuser.hublot}", + name=f"{DOMAIN}-{hub.hublot}", update_interval=update_interval, ) @override - async def _async_update_data(self) -> None: - """Fetch data from Rituals, with one silent re-auth on 401. - - If silent re-auth also fails, raise ConfigEntryAuthFailed - to trigger reauth flow. - Other HTTP/network errors are wrapped in UpdateFailed so HA can retry. - """ + async def _async_update_data(self) -> RitualsData: + """Fetch data from Rituals.""" try: - await self.diffuser.update_data() - except (AuthenticationException, ClientResponseError) as err: - # Treat 401/403 like AuthenticationException: - # one silent re-auth, single retry - if isinstance(err, ClientResponseError) and (status := err.status) not in ( - 401, - 403, - ): - # Non-auth HTTP error → let HA retry - raise UpdateFailed(f"HTTP {status}") from err + hub = await self.client.hub(self.hub_hash) + sensors = await self.client.sensors(hub, only=SENSORS) + except RitualsGenieAuthenticationError as err: + raise ConfigEntryAuthFailed from err + except RitualsGenieRateLimitError as err: + raise UpdateFailed(str(err), retry_after=err.retry_after) from err + except RitualsGenieError as err: + raise UpdateFailed(str(err)) from err - self.logger.debug( - "Auth issue detected (%r). Attempting silent re-auth.", err - ) - try: - await self.account.authenticate() - await self.diffuser.update_data() - except AuthenticationException as err2: - # Credentials invalid → trigger HA reauth - raise ConfigEntryAuthFailed from err2 - except ClientResponseError as err2: - # Still HTTP auth errors after refresh → trigger HA reauth - if err2.status in (401, 403): - raise ConfigEntryAuthFailed from err2 - raise UpdateFailed(f"HTTP {err2.status}") from err2 - except ClientError as err: - # Network issues (timeouts, DNS, etc.) - raise UpdateFailed(f"Network error: {err!r}") from err + return RitualsData(hub=hub, sensors=sensors) diff --git a/homeassistant/components/rituals_perfume_genie/diagnostics.py b/homeassistant/components/rituals_perfume_genie/diagnostics.py index b98e5d3fcd8c..8c09f2e31409 100644 --- a/homeassistant/components/rituals_perfume_genie/diagnostics.py +++ b/homeassistant/components/rituals_perfume_genie/diagnostics.py @@ -1,5 +1,6 @@ """Diagnostics support for Rituals Perfume Genie.""" +from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -19,7 +20,13 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" return { "diffusers": [ - async_redact_data(coordinator.diffuser.data, TO_REDACT) + async_redact_data( + { + "hub": coordinator.data.hub.to_dict(), + "sensors": asdict(coordinator.data.sensors), + }, + TO_REDACT, + ) for coordinator in entry.runtime_data.values() ] } diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index 6e10dee9c78d..17397127bb46 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -1,6 +1,6 @@ """Base class for Rituals Perfume Genie diffuser entity.""" -from typing import Any, override +from typing import override from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -14,12 +14,6 @@ MODEL = "The Perfume Genie" MODEL2 = "The Perfume Genie 2.0" -def _version_string(version: Any) -> str: - if isinstance(version, dict): - return str(version.get("title", version)) - return str(version) - - class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]): """Representation of a diffuser entity.""" @@ -33,17 +27,21 @@ class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]): """Init from config, hookup diffuser and coordinator.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" + + hub = coordinator.data.hub + firmware = hub.firmware.current if hub.firmware else None + + self._attr_unique_id = f"{coordinator.hublot}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.diffuser.hublot)}, + identifiers={(DOMAIN, coordinator.hublot)}, manufacturer=MANUFACTURER, - model=MODEL if coordinator.diffuser.has_battery else MODEL2, - name=coordinator.diffuser.name, - sw_version=_version_string(coordinator.diffuser.version), + model=MODEL if hub.has_battery else MODEL2, + name=hub.name, + sw_version=str(firmware) if firmware else None, ) @property @override def available(self) -> bool: """Return if the entity is available.""" - return super().available and self.coordinator.diffuser.is_online + return super().available and self.coordinator.data.hub.is_online is not False diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json index 39d5084c1a3b..b68e78325e82 100644 --- a/homeassistant/components/rituals_perfume_genie/manifest.json +++ b/homeassistant/components/rituals_perfume_genie/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie", "integration_type": "hub", "iot_class": "cloud_polling", - "loggers": ["pyrituals"], - "requirements": ["pyrituals==0.0.7"] + "loggers": ["ritualsgenie"], + "requirements": ["ritualsgenie==0.1.0"] } diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 567210cfbc10..de3c5849e891 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -2,15 +2,15 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, override +from typing import override -from pyrituals import Diffuser +from ritualsgenie import Attribute, RitualsGenie from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import RitualsConfigEntry +from .coordinator import RitualsConfigEntry, RitualsData from .entity import DiffuserEntity PARALLEL_UPDATES = 1 @@ -20,18 +20,22 @@ PARALLEL_UPDATES = 1 class RitualsNumberEntityDescription(NumberEntityDescription): """Class describing Rituals number entities.""" - value_fn: Callable[[Diffuser], int] - set_value_fn: Callable[[Diffuser, int], Awaitable[Any]] + attribute: Attribute + value_fn: Callable[[RitualsData], int | None] + set_value_fn: Callable[[RitualsGenie, str, int], Awaitable[None]] ENTITY_DESCRIPTIONS = ( RitualsNumberEntityDescription( key="perfume_amount", translation_key="perfume_amount", + attribute=Attribute.PERFUME_AMOUNT, native_min_value=1, native_max_value=3, - value_fn=lambda diffuser: diffuser.perfume_amount, - set_value_fn=lambda diffuser, value: diffuser.set_perfume_amount(value), + value_fn=lambda data: data.hub.perfume_amount, + set_value_fn=lambda client, hub_hash, value: client.set_perfume_amount( + hub_hash, value + ), ), ) @@ -57,9 +61,9 @@ class RitualsNumberEntity(DiffuserEntity, NumberEntity): @property @override - def native_value(self) -> int: + def native_value(self) -> int | None: """Return the number value.""" - return self.entity_description.value_fn(self.coordinator.diffuser) + return self.entity_description.value_fn(self.coordinator.data) @override async def async_set_native_value(self, value: float) -> None: @@ -67,5 +71,10 @@ class RitualsNumberEntity(DiffuserEntity, NumberEntity): if not value.is_integer(): raise ValueError(f"Can't set value to {value}. Value must be an integer.") await self.entity_description.set_value_fn( - self.coordinator.diffuser, int(value) + self.coordinator.client, self.coordinator.hub_hash, int(value) ) + + # Keep the new value until the next update, like the device has it now. + attribute_values = self.coordinator.data.hub.attribute_values + attribute_values[self.entity_description.attribute] = str(int(value)) + self.async_write_ha_state() diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 8364e3a102a4..31bb50e1e6ea 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -4,14 +4,14 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import override -from pyrituals import Diffuser +from ritualsgenie import Attribute, RitualsGenie, RoomSize from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory, UnitOfArea from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import RitualsConfigEntry, RitualsDataUpdateCoordinator +from .coordinator import RitualsConfigEntry, RitualsData, RitualsDataUpdateCoordinator from .entity import DiffuserEntity PARALLEL_UPDATES = 1 @@ -21,8 +21,13 @@ PARALLEL_UPDATES = 1 class RitualsSelectEntityDescription(SelectEntityDescription): """Class describing Rituals select entities.""" - current_fn: Callable[[Diffuser], str] - select_fn: Callable[[Diffuser, str], Awaitable[None]] + current_fn: Callable[[RitualsData], str | None] + select_fn: Callable[[RitualsGenie, str, str], Awaitable[None]] + + +def _room_size(square_meters: str) -> RoomSize: + """Return the room size category of one of the options.""" + return next(size for size in RoomSize if size.square_meters == int(square_meters)) ENTITY_DESCRIPTIONS = ( @@ -32,9 +37,11 @@ ENTITY_DESCRIPTIONS = ( unit_of_measurement=UnitOfArea.SQUARE_METERS, entity_category=EntityCategory.CONFIG, options=["15", "30", "60", "100"], - current_fn=lambda diffuser: str(diffuser.room_size_square_meter), - select_fn=lambda diffuser, value: diffuser.set_room_size_square_meter( - int(value) + current_fn=lambda data: ( + str(data.hub.room_size.square_meters) if data.hub.room_size else None + ), + select_fn=lambda client, hub_hash, value: client.set_room_size_category( + hub_hash, _room_size(value) ), ), ) @@ -68,16 +75,23 @@ class RitualsSelectEntity(DiffuserEntity, SelectEntity): """Initialize the diffuser room size select entity.""" super().__init__(coordinator, description) self._attr_entity_registry_enabled_default = ( - self.coordinator.diffuser.has_battery + self.coordinator.data.hub.has_battery ) @property @override - def current_option(self) -> str: + def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - return self.entity_description.current_fn(self.coordinator.diffuser) + return self.entity_description.current_fn(self.coordinator.data) @override async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self.entity_description.select_fn(self.coordinator.diffuser, option) + await self.entity_description.select_fn( + self.coordinator.client, self.coordinator.hub_hash, option + ) + + # Keep the new value until the next update, like the device has it now. + attribute_values = self.coordinator.data.hub.attribute_values + attribute_values[Attribute.ROOM_SIZE] = str(int(_room_size(option))) + self.async_write_ha_state() diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index b78d18869c14..cbb8db97955b 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import override -from pyrituals import Diffuser +from ritualsgenie import Sensor from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,7 +15,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import RitualsConfigEntry +from .coordinator import RitualsConfigEntry, RitualsData from .entity import DiffuserEntity PARALLEL_UPDATES = 0 @@ -25,8 +25,8 @@ PARALLEL_UPDATES = 0 class RitualsSensorEntityDescription(SensorEntityDescription): """Class describing Rituals sensor entities.""" - has_fn: Callable[[Diffuser], bool] = lambda _: True - value_fn: Callable[[Diffuser], int | str] + has_fn: Callable[[RitualsData], bool] = lambda _: True + value_fn: Callable[[RitualsData], int | str | None] ENTITY_DESCRIPTIONS = ( @@ -34,25 +34,27 @@ ENTITY_DESCRIPTIONS = ( key="battery_percentage", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, - value_fn=lambda diffuser: diffuser.battery_percentage, - has_fn=lambda diffuser: diffuser.has_battery, + value_fn=lambda data: data.sensors.battery_percentage, + has_fn=lambda data: data.hub.has_battery, ), RitualsSensorEntityDescription( key="fill", translation_key="fill", - value_fn=lambda diffuser: diffuser.fill, - has_fn=lambda diffuser: "fillc" in diffuser.hub_data.get("sensors", {}), + value_fn=lambda data: data.sensors.fill.title if data.sensors.fill else None, + has_fn=lambda data: Sensor.FILL in data.hub.supported_sensors, ), RitualsSensorEntityDescription( key="perfume", translation_key="perfume", - value_fn=lambda diffuser: diffuser.perfume, + value_fn=lambda data: ( + data.sensors.perfume.title if data.sensors.perfume else None + ), ), RitualsSensorEntityDescription( key="wifi_percentage", translation_key="wifi_percentage", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda diffuser: diffuser.wifi_percentage, + value_fn=lambda data: data.sensors.wifi_percentage, ), ) @@ -69,7 +71,7 @@ async def async_setup_entry( RitualsSensorEntity(coordinator, description) for coordinator in coordinators.values() for description in ENTITY_DESCRIPTIONS - if description.has_fn(coordinator.diffuser) + if description.has_fn(coordinator.data) ) @@ -81,6 +83,6 @@ class RitualsSensorEntity(DiffuserEntity, SensorEntity): @property @override - def native_value(self) -> str | int: + def native_value(self) -> str | int | None: """Return the sensor value.""" - return self.entity_description.value_fn(self.coordinator.diffuser) + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 6a5868455dc4..40ec2ec8c886 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -4,13 +4,13 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, override -from pyrituals import Diffuser +from ritualsgenie import RitualsGenie from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import RitualsConfigEntry, RitualsDataUpdateCoordinator +from .coordinator import RitualsConfigEntry, RitualsData, RitualsDataUpdateCoordinator from .entity import DiffuserEntity PARALLEL_UPDATES = 1 @@ -20,9 +20,9 @@ PARALLEL_UPDATES = 1 class RitualsSwitchEntityDescription(SwitchEntityDescription): """Class describing Rituals switch entities.""" - is_on_fn: Callable[[Diffuser], bool] - turn_on_fn: Callable[[Diffuser], Awaitable[None]] - turn_off_fn: Callable[[Diffuser], Awaitable[None]] + is_on_fn: Callable[[RitualsData], bool | None] + turn_on_fn: Callable[[RitualsGenie, str], Awaitable[None]] + turn_off_fn: Callable[[RitualsGenie, str], Awaitable[None]] ENTITY_DESCRIPTIONS = ( @@ -30,9 +30,9 @@ ENTITY_DESCRIPTIONS = ( key="is_on", name=None, translation_key="fan", - is_on_fn=lambda diffuser: diffuser.is_on, - turn_on_fn=lambda diffuser: diffuser.turn_on(), - turn_off_fn=lambda diffuser: diffuser.turn_off(), + is_on_fn=lambda data: data.hub.is_on, + turn_on_fn=lambda client, hub_hash: client.turn_on(hub_hash), + turn_off_fn=lambda client, hub_hash: client.turn_off(hub_hash), ), ) @@ -64,19 +64,23 @@ class RitualsSwitchEntity(DiffuserEntity, SwitchEntity): ) -> None: """Initialize the diffuser switch.""" super().__init__(coordinator, description) - self._attr_is_on = description.is_on_fn(coordinator.diffuser) + self._attr_is_on = description.is_on_fn(coordinator.data) @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.entity_description.turn_on_fn(self.coordinator.diffuser) + await self.entity_description.turn_on_fn( + self.coordinator.client, self.coordinator.hub_hash + ) self._attr_is_on = True self.async_write_ha_state() @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.entity_description.turn_off_fn(self.coordinator.diffuser) + await self.entity_description.turn_off_fn( + self.coordinator.client, self.coordinator.hub_hash + ) self._attr_is_on = False self.async_write_ha_state() @@ -84,5 +88,5 @@ class RitualsSwitchEntity(DiffuserEntity, SwitchEntity): @override def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._attr_is_on = self.entity_description.is_on_fn(self.coordinator.diffuser) + self._attr_is_on = self.entity_description.is_on_fn(self.coordinator.data) super()._handle_coordinator_update() diff --git a/requirements_all.txt b/requirements_all.txt index 551f3f1a2ab3..43e18e9c389b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2573,9 +2573,6 @@ pyrepetierng==0.1.0 # homeassistant.components.risco pyrisco==0.8.1 -# homeassistant.components.rituals_perfume_genie -pyrituals==0.0.7 - # homeassistant.components.thread pyroute2==0.9.6 @@ -2989,6 +2986,9 @@ ring-doorbell==0.9.14 # homeassistant.components.fleetgo ritassist==0.9.2 +# homeassistant.components.rituals_perfume_genie +ritualsgenie==0.1.0 + # homeassistant.components.rocketchat rocketchat-API==0.6.1 diff --git a/tests/components/rituals_perfume_genie/common.py b/tests/components/rituals_perfume_genie/common.py index 6bdcaadd4c4d..11223ee4cd0e 100644 --- a/tests/components/rituals_perfume_genie/common.py +++ b/tests/components/rituals_perfume_genie/common.py @@ -1,13 +1,29 @@ """Common methods used across tests for Rituals Perfume Genie.""" -from unittest.mock import AsyncMock, MagicMock, patch +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import AsyncMock, create_autospec, patch + +from ritualsgenie import ( + RitualsGenie, + RitualsGenieHub, + RitualsGenieSensor, + RitualsGenieSensors, +) from homeassistant.components.rituals_perfume_genie.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry + +ROOM_SIZES = {15: "1", 30: "2", 60: "3", 100: "4"} +WIFI_ICONS = { + 100: "icon-signal.png", + 75: "icon-signal-75.png", + 25: "icon-signal-low.png", +} def mock_config_entry(unique_id: str, entry_id: str = "an_entry_id") -> MockConfigEntry: @@ -25,101 +41,141 @@ def mock_config_entry(unique_id: str, entry_id: str = "an_entry_id") -> MockConf ) -def mock_diffuser( - hublot: str, - available: bool = True, - battery_percentage: int | Exception = 100, - charging: bool | Exception = True, - fill: str = "90-100%", - has_battery: bool = True, - has_cartridge: bool = True, - is_on: bool = True, - name: str = "Genie", - perfume: str = "Ritual of Sakura", - perfume_amount: int = 2, - room_size_square_meter: int = 60, - version: str = "4.0", - wifi_percentage: int = 75, -) -> MagicMock: - """Return a mock Diffuser initialized with the given data.""" - diffuser_mock = MagicMock() - diffuser_mock.available = available - diffuser_mock.battery_percentage = battery_percentage - diffuser_mock.charging = charging - diffuser_mock.fill = fill - diffuser_mock.has_battery = has_battery - diffuser_mock.has_cartridge = has_cartridge - diffuser_mock.hublot = hublot - diffuser_mock.is_on = is_on - diffuser_mock.name = name - diffuser_mock.perfume = perfume - diffuser_mock.perfume_amount = perfume_amount - diffuser_mock.room_size_square_meter = room_size_square_meter - diffuser_mock.set_perfume_amount = AsyncMock() - diffuser_mock.set_room_size_square_meter = AsyncMock() - diffuser_mock.turn_off = AsyncMock() - diffuser_mock.turn_on = AsyncMock() - diffuser_mock.update_data = AsyncMock() - diffuser_mock.version = version - diffuser_mock.wifi_percentage = wifi_percentage - diffuser_mock.data = load_json_object_fixture("data.json", DOMAIN) - diffuser_mock.hub_data = diffuser_mock.data["hub"] - return diffuser_mock +@dataclass +class MockDiffuser: + """A diffuser, as the API would report it right now.""" + + hublot: str + available: bool = True + charging: bool = True + fill: str | None = "90-100%" + has_battery: bool = True + has_cartridge: bool = True + is_on: bool = True + name: str = "Genie" + perfume: str = "Ritual of Sakura" + perfume_amount: int = 2 + room_size_square_meter: int = 60 + version: str = "4.0" + wifi_percentage: int = 75 + battery_percentage: int = field(default=100, init=False) + + @property + def hub_hash(self) -> str: + """Return the hash of the diffuser.""" + return f"hash-{self.hublot}" + + def hub(self) -> RitualsGenieHub: + """Return the hub, as the API would send it.""" + return RitualsGenieHub.from_dict( + { + "hash": self.hub_hash, + "hublot": self.hublot, + "status": 1 if self.available else 0, + "attributeValues": { + "fanc": "1" if self.is_on else "0", + "speedc": str(self.perfume_amount), + "roomc": ROOM_SIZES[self.room_size_square_meter], + "roomnamec": self.name, + }, + "sensors": { + "battc": self.has_battery, + "fillc": self.fill is not None, + "rfidc": True, + "versionc": True, + "wific": True, + }, + "firmwareInfo": { + "currentFirmware": self.version, + "currentFirmwareId": 127, + "newestFirmware": self.version, + "newestFirmwareId": 127, + }, + } + ) + + def sensors(self) -> RitualsGenieSensors: + """Return the sensor readings, as the API would send them.""" + return RitualsGenieSensors( + battery=( + RitualsGenieSensor( + icon="battery-full.png", id=21 if self.charging else 1 + ) + if self.has_battery + else None + ), + fill=RitualsGenieSensor(title=self.fill, raw="1000") if self.fill else None, + perfume=RitualsGenieSensor( + title=self.perfume, raw="048616d0" if self.has_cartridge else "0" + ), + wifi=RitualsGenieSensor(icon=WIFI_ICONS[self.wifi_percentage]), + ) -def mock_diffuser_v1_battery_cartridge() -> MagicMock: +def mock_diffuser(hublot: str, **kwargs: Any) -> MockDiffuser: + """Return a mock diffuser initialized with the given data.""" + return MockDiffuser(hublot=hublot, **kwargs) + + +def mock_diffuser_v1_battery_cartridge() -> MockDiffuser: """Create and return a mock version 1 Diffuser with battery and a cartridge.""" return mock_diffuser(hublot="lot123v1") -def mock_diffuser_v3_no_battery_no_fill() -> MagicMock: +def mock_diffuser_v3_no_battery_no_fill() -> MockDiffuser: """Create and return a mock version 3 Diffuser without battery or fill sensor.""" - diffuser = mock_diffuser( + return mock_diffuser( hublot="lot123v3", - battery_percentage=Exception(), - charging=Exception(), - fill="", + fill=None, has_battery=False, has_cartridge=True, name="Genie V3", perfume="Ritual of Sakura", version="6.0", ) - diffuser.data = load_json_object_fixture("data_no_fill.json", DOMAIN) - diffuser.hub_data = diffuser.data["hub"] - return diffuser -def mock_diffuser_v2_no_battery_no_cartridge() -> MagicMock: +def mock_diffuser_v2_no_battery_no_cartridge() -> MockDiffuser: """Create and return a mock version 2 Diffuser without battery and cartridge.""" return mock_diffuser( hublot="lot123v2", - battery_percentage=Exception(), - charging=Exception(), has_battery=False, has_cartridge=False, name="Genie V2", - perfume="No Cartridge", + perfume="Cartridge is not loaded", version="5.0", ) +def mock_client(diffusers: list[MockDiffuser]) -> AsyncMock: + """Return a mock client that answers with the given diffusers.""" + by_hash = {diffuser.hub_hash: diffuser for diffuser in diffusers} + + client = create_autospec(RitualsGenie, instance=True) + client.hubs.side_effect = lambda: [diffuser.hub() for diffuser in diffusers] + client.hub.side_effect = lambda hub_hash: by_hash[hub_hash].hub() + client.sensors.side_effect = lambda hub, only=None: by_hash[hub.hash].sensors() + + return client + + async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_diffusers: list[MagicMock], -) -> None: + mock_diffusers: list[MockDiffuser], +) -> AsyncMock: """Initialize Rituals Perfume Genie with given entry and diffusers.""" mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.rituals_perfume_genie.Account" - ) as mock_account_cls: - mock_account = mock_account_cls.return_value - mock_account.authenticate = AsyncMock() - mock_account.get_devices = AsyncMock(return_value=mock_diffusers) + client = mock_client(mock_diffusers) + with patch( + "homeassistant.components.rituals_perfume_genie.RitualsGenie", + return_value=client, + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.runtime_data + + return client diff --git a/tests/components/rituals_perfume_genie/conftest.py b/tests/components/rituals_perfume_genie/conftest.py index 44e9e125040c..4ee42cf9248a 100644 --- a/tests/components/rituals_perfume_genie/conftest.py +++ b/tests/components/rituals_perfume_genie/conftest.py @@ -24,20 +24,19 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_rituals_account() -> Generator[AsyncMock]: - """Mock Rituals Account.""" +def mock_rituals_client() -> Generator[AsyncMock]: + """Mock the Rituals Perfume Genie client.""" with ( patch( - "homeassistant.components.rituals_perfume_genie.config_flow.Account", + "homeassistant.components.rituals_perfume_genie.config_flow.RitualsGenie", autospec=True, - ) as mock_account_cls, + ) as mock_client_cls, patch( - "homeassistant.components.rituals_perfume_genie.Account", - new=mock_account_cls, + "homeassistant.components.rituals_perfume_genie.RitualsGenie", + new=mock_client_cls, ), ): - mock_account = mock_account_cls.return_value - yield mock_account + yield mock_client_cls.return_value @pytest.fixture diff --git a/tests/components/rituals_perfume_genie/fixtures/data.json b/tests/components/rituals_perfume_genie/fixtures/data.json deleted file mode 100644 index e583c87e3daf..000000000000 --- a/tests/components/rituals_perfume_genie/fixtures/data.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "hub": { - "hublot": "LOT123", - "hash": "1234567890abcdefgghijklmnopqrstuvwxyz", - "status": 1, - "title": null, - "current_time": "2023-06-09T20:50", - "cached_time": "2023-06-09T20:48", - "ping_update": "25", - "attributes": { - "roomc": "4", - "speedc": "3", - "fanc": "1", - "roomnamec": "Living room", - "resetc": "", - "fspacenamec": "", - "fspacetypec": "" - }, - "sensors": { - "wific": { - "id": 10, - "sensor_id": 1, - "title": "High", - "description": "", - "icon": "icon-signal.png", - "image": "", - "discover_image": "", - "discover_url": null, - "min_value": "-69.99", - "max_value": "-0.00", - "interval": "1", - "created_at": "2017-03-10 16:17:30", - "updated_at": "2020-06-17 16:57:53", - "default": 0 - }, - "fillc": { - "id": 38, - "sensor_id": 3, - "title": "90-100%", - "description": "", - "icon": "icon-fill.png", - "image": "", - "discover_image": "", - "discover_url": "", - "min_value": "0", - "max_value": "2000", - "interval": "", - "created_at": "2017-10-16 07:50:23", - "updated_at": "2021-02-17 13:42:16", - "default": 0 - }, - "rfidc": { - "id": 54, - "sensor_id": 4, - "title": "Private Collection Sweet Jasmine", - "description": "", - "icon": "icon-jasmine.png", - "image": "background-jasmine.png", - "discover_image": "discover-jasmine.png", - "discover_url": "sweet-jasmine-cartridge-1105402.html", - "min_value": "05377650", - "max_value": "05377650", - "interval": "", - "created_at": "2019-04-04 07:53:32", - "updated_at": "2021-01-26 14:17:03", - "default": 0 - }, - "versionc": "5.2-rc15", - "ipc": "1682963060", - "rpsc": { - "id": 48, - "sensor_id": 12, - "title": "Fan on", - "description": "", - "icon": "", - "image": "", - "discover_image": "", - "discover_url": null, - "min_value": "5", - "max_value": "10000", - "interval": "", - "created_at": "2018-01-23 12:05:45", - "updated_at": "2019-08-01 14:54:53", - "default": 0 - }, - "resetc": "External System", - "chipidc": "9820410", - "errorc": "", - "onlinec": { - "id": 31, - "sensor_id": 16, - "title": "Online", - "description": "", - "icon": "", - "image": "", - "discover_image": "", - "discover_url": null, - "min_value": "1", - "max_value": "1", - "interval": "", - "created_at": "2017-09-07 08:23:30", - "updated_at": "2017-09-07 08:23:30", - "default": 0 - } - }, - "settings": [ - { - "schedule_id": 1835730, - "start": "07:30", - "end": "08:30", - "mon": 1, - "tue": 1, - "wed": 1, - "thu": 1, - "fri": 1, - "sat": 1, - "sun": 1 - } - ] - } -} diff --git a/tests/components/rituals_perfume_genie/fixtures/data_no_fill.json b/tests/components/rituals_perfume_genie/fixtures/data_no_fill.json deleted file mode 100644 index 0d1f07077c0c..000000000000 --- a/tests/components/rituals_perfume_genie/fixtures/data_no_fill.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "hub": { - "hublot": "LOT456", - "hash": "abcdef1234567890ghijklmnopqrstuvwxyz", - "status": 1, - "title": null, - "current_time": "2023-06-09T20:50", - "cached_time": "2023-06-09T20:48", - "ping_update": "25", - "attributes": { - "roomc": "4", - "speedc": "3", - "fanc": "1", - "roomnamec": "Living room", - "resetc": "", - "fspacenamec": "", - "fspacetypec": "" - }, - "sensors": { - "wific": { - "id": 10, - "sensor_id": 1, - "title": "High", - "description": "", - "icon": "icon-signal.png", - "image": "", - "discover_image": "", - "discover_url": null, - "min_value": "-69.99", - "max_value": "-0.00", - "interval": "1", - "created_at": "2017-03-10 16:17:30", - "updated_at": "2020-06-17 16:57:53", - "default": 0 - }, - "rfidc": { - "id": 54, - "sensor_id": 4, - "title": "Private Collection Sweet Jasmine", - "description": "", - "icon": "icon-jasmine.png", - "image": "background-jasmine.png", - "discover_image": "discover-jasmine.png", - "discover_url": "sweet-jasmine-cartridge-1105402.html", - "min_value": "05377650", - "max_value": "05377650", - "interval": "", - "created_at": "2019-04-04 07:53:32", - "updated_at": "2021-01-26 14:17:03", - "default": 0 - }, - "versionc": "6.0", - "ipc": "1682963060" - }, - "settings": [ - { - "schedule_id": 1835730, - "start": "07:30", - "end": "08:30", - "mon": 1, - "tue": 1, - "wed": 1, - "thu": 1, - "fri": 1, - "sat": 1, - "sun": 1 - } - ] - } -} diff --git a/tests/components/rituals_perfume_genie/snapshots/test_diagnostics.ambr b/tests/components/rituals_perfume_genie/snapshots/test_diagnostics.ambr index 4edc7622af75..749e58b3548b 100644 --- a/tests/components/rituals_perfume_genie/snapshots/test_diagnostics.ambr +++ b/tests/components/rituals_perfume_genie/snapshots/test_diagnostics.ambr @@ -4,123 +4,78 @@ 'diffusers': list([ dict({ 'hub': dict({ - 'attributes': dict({ + 'attributeValues': dict({ 'fanc': '1', - 'fspacenamec': '', - 'fspacetypec': '', - 'resetc': '', - 'roomc': '4', - 'roomnamec': 'Living room', - 'speedc': '3', + 'roomc': '3', + 'roomnamec': 'Genie', + 'speedc': '2', + }), + 'attributes': dict({ + }), + 'firmwareInfo': dict({ + 'currentFirmware': '4.0', + 'currentFirmwareId': 127, + 'newestFirmware': '4.0', + 'newestFirmwareId': 127, }), - 'cached_time': '2023-06-09T20:48', - 'current_time': '2023-06-09T20:50', 'hash': '**REDACTED**', 'hublot': '**REDACTED**', - 'ping_update': '25', 'sensors': dict({ - 'chipidc': '9820410', - 'errorc': '', - 'fillc': dict({ - 'created_at': '2017-10-16 07:50:23', - 'default': 0, - 'description': '', - 'discover_image': '', - 'discover_url': '', - 'icon': 'icon-fill.png', - 'id': 38, - 'image': '', - 'interval': '', - 'max_value': '2000', - 'min_value': '0', - 'sensor_id': 3, - 'title': '90-100%', - 'updated_at': '2021-02-17 13:42:16', - }), - 'ipc': '1682963060', - 'onlinec': dict({ - 'created_at': '2017-09-07 08:23:30', - 'default': 0, - 'description': '', - 'discover_image': '', - 'discover_url': None, - 'icon': '', - 'id': 31, - 'image': '', - 'interval': '', - 'max_value': '1', - 'min_value': '1', - 'sensor_id': 16, - 'title': 'Online', - 'updated_at': '2017-09-07 08:23:30', - }), - 'resetc': 'External System', - 'rfidc': dict({ - 'created_at': '2019-04-04 07:53:32', - 'default': 0, - 'description': '', - 'discover_image': 'discover-jasmine.png', - 'discover_url': 'sweet-jasmine-cartridge-1105402.html', - 'icon': 'icon-jasmine.png', - 'id': 54, - 'image': 'background-jasmine.png', - 'interval': '', - 'max_value': '05377650', - 'min_value': '05377650', - 'sensor_id': 4, - 'title': 'Private Collection Sweet Jasmine', - 'updated_at': '2021-01-26 14:17:03', - }), - 'rpsc': dict({ - 'created_at': '2018-01-23 12:05:45', - 'default': 0, - 'description': '', - 'discover_image': '', - 'discover_url': None, - 'icon': '', - 'id': 48, - 'image': '', - 'interval': '', - 'max_value': '10000', - 'min_value': '5', - 'sensor_id': 12, - 'title': 'Fan on', - 'updated_at': '2019-08-01 14:54:53', - }), - 'versionc': '5.2-rc15', - 'wific': dict({ - 'created_at': '2017-03-10 16:17:30', - 'default': 0, - 'description': '', - 'discover_image': '', - 'discover_url': None, - 'icon': 'icon-signal.png', - 'id': 10, - 'image': '', - 'interval': '1', - 'max_value': '-0.00', - 'min_value': '-69.99', - 'sensor_id': 1, - 'title': 'High', - 'updated_at': '2020-06-17 16:57:53', - }), + 'battc': True, + 'fillc': True, + 'rfidc': True, + 'versionc': True, + 'wific': True, }), - 'settings': list([ - dict({ - 'end': '08:30', - 'fri': 1, - 'mon': 1, - 'sat': 1, - 'schedule_id': 1835730, - 'start': '07:30', - 'sun': 1, - 'thu': 1, - 'tue': 1, - 'wed': 1, - }), - ]), 'status': 1, - 'title': None, + }), + 'sensors': dict({ + 'battery': dict({ + 'description': None, + 'discover_url': None, + 'icon': 'battery-full.png', + 'id': 21, + 'image': None, + 'raw': None, + 'scent_found': None, + 'title': None, + 'value': None, + }), + 'fill': dict({ + 'description': None, + 'discover_url': None, + 'icon': None, + 'id': None, + 'image': None, + 'raw': '1000', + 'scent_found': None, + 'title': '90-100%', + 'value': None, + }), + 'generation': None, + 'perfume': dict({ + 'description': None, + 'discover_url': None, + 'icon': None, + 'id': None, + 'image': None, + 'raw': '048616d0', + 'scent_found': None, + 'title': 'Ritual of Sakura', + 'value': None, + }), + 'version': None, + 'wifi': dict({ + 'description': None, + 'discover_url': None, + 'icon': 'icon-signal-75.png', + 'id': None, + 'image': None, + 'raw': None, + 'scent_found': None, + 'title': None, + 'value': None, + }), }), }), ]), diff --git a/tests/components/rituals_perfume_genie/test_config_flow.py b/tests/components/rituals_perfume_genie/test_config_flow.py index 8c1989c214c9..a4c9245f4f87 100644 --- a/tests/components/rituals_perfume_genie/test_config_flow.py +++ b/tests/components/rituals_perfume_genie/test_config_flow.py @@ -2,9 +2,12 @@ from unittest.mock import AsyncMock -from aiohttp import ClientError -from pyrituals import AuthenticationException import pytest +from ritualsgenie import ( + RitualsGenieAuthenticationError, + RitualsGenieConnectionError, + RitualsGenieRateLimitError, +) from homeassistant.components.rituals_perfume_genie.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -18,7 +21,7 @@ from tests.common import MockConfigEntry async def test_user_flow_success( - hass: HomeAssistant, mock_rituals_account: AsyncMock, mock_setup_entry: AsyncMock + hass: HomeAssistant, mock_rituals_client: AsyncMock, mock_setup_entry: AsyncMock ) -> None: """Test successful user flow setup.""" result = await hass.config_entries.flow.async_init( @@ -49,14 +52,15 @@ async def test_user_flow_success( @pytest.mark.parametrize( ("exception", "error"), [ - (AuthenticationException, "invalid_auth"), - (ClientError, "cannot_connect"), + (RitualsGenieAuthenticationError, "invalid_auth"), + (RitualsGenieConnectionError, "cannot_connect"), + (RitualsGenieRateLimitError("Too many login attempts"), "cannot_connect"), ], ) @pytest.mark.usefixtures("mock_setup_entry") async def test_user_flow_errors( hass: HomeAssistant, - mock_rituals_account: AsyncMock, + mock_rituals_client: AsyncMock, exception: Exception, error: str, ) -> None: @@ -64,7 +68,7 @@ async def test_user_flow_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - mock_rituals_account.authenticate.side_effect = exception + mock_rituals_client.login.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -77,7 +81,7 @@ async def test_user_flow_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_rituals_account.authenticate.side_effect = None + mock_rituals_client.login.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -92,7 +96,7 @@ async def test_user_flow_errors( async def test_duplicate_entry( hass: HomeAssistant, - mock_rituals_account: AsyncMock, + mock_rituals_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test user flow with invalid credentials.""" @@ -115,7 +119,7 @@ async def test_duplicate_entry( async def test_reauth_flow_success( hass: HomeAssistant, - mock_rituals_account: AsyncMock, + mock_rituals_client: AsyncMock, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: @@ -141,14 +145,15 @@ async def test_reauth_flow_success( @pytest.mark.parametrize( ("exception", "error"), [ - (AuthenticationException, "invalid_auth"), - (ClientError, "cannot_connect"), + (RitualsGenieAuthenticationError, "invalid_auth"), + (RitualsGenieConnectionError, "cannot_connect"), + (RitualsGenieRateLimitError("Too many login attempts"), "cannot_connect"), ], ) @pytest.mark.usefixtures("mock_setup_entry") async def test_reauth_flow_errors( hass: HomeAssistant, - mock_rituals_account: AsyncMock, + mock_rituals_client: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, error: str, @@ -157,7 +162,7 @@ async def test_reauth_flow_errors( mock_config_entry.add_to_hass(hass) result = await mock_config_entry.start_reauth_flow(hass) - mock_rituals_account.authenticate.side_effect = exception + mock_rituals_client.login.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -167,7 +172,7 @@ async def test_reauth_flow_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_rituals_account.authenticate.side_effect = None + mock_rituals_client.login.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -182,7 +187,7 @@ async def test_reauth_flow_errors( async def test_reauth_migrated_entry( - hass: HomeAssistant, mock_rituals_account: AsyncMock, mock_setup_entry: AsyncMock + hass: HomeAssistant, mock_rituals_client: AsyncMock, mock_setup_entry: AsyncMock ) -> None: """Test successful reauth flow (updating credentials).""" mock_config_entry = MockConfigEntry( diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index c4bf08c191b7..d71a4d8279fc 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -1,11 +1,20 @@ """Tests for the Rituals Perfume Genie integration.""" +from datetime import timedelta from unittest.mock import AsyncMock -import aiohttp +from freezegun.api import FrozenDateTimeFactory +import pytest +from ritualsgenie import ( + RitualsGenieAuthenticationError, + RitualsGenieConnectionError, + RitualsGenieError, + RitualsGenieRateLimitError, +) from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -16,12 +25,12 @@ from .common import ( mock_diffuser_v1_battery_cartridge, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_migration_v1_to_v2( hass: HomeAssistant, - mock_rituals_account: AsyncMock, + mock_rituals_client: AsyncMock, old_mock_config_entry: MockConfigEntry, ) -> None: """Test migration from V1 (account_hash) to V2 (credentials).""" @@ -38,12 +47,12 @@ async def test_migration_v1_to_v2( async def test_config_entry_not_ready( hass: HomeAssistant, - mock_rituals_account: AsyncMock, + mock_rituals_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test entry setup when connection to Rituals is missing.""" mock_config_entry.add_to_hass(hass) - mock_rituals_account.get_devices.side_effect = aiohttp.ClientError + mock_rituals_client.hubs.side_effect = RitualsGenieConnectionError await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -51,6 +60,69 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_config_entry_auth_failed( + hass: HomeAssistant, + mock_rituals_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entry setup with invalid credentials starts a reauth flow.""" + mock_config_entry.add_to_hass(hass) + mock_rituals_client.hubs.side_effect = RitualsGenieAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + + +@pytest.mark.parametrize( + "exception", + [ + RitualsGenieConnectionError, + RitualsGenieError, + RitualsGenieRateLimitError("Slow down", retry_after=60), + ], +) +async def test_update_failed( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test entities become unavailable when updating fails.""" + config_entry = mock_config_entry(unique_id="id_123_update_failed") + client = await init_integration(hass, config_entry, [mock_diffuser("lot123")]) + client.hub.side_effect = exception + + freezer.tick(timedelta(minutes=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("switch.genie") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_update_auth_failed( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test a reauth flow is started when the credentials stopped working.""" + config_entry = mock_config_entry(unique_id="id_123_update_auth_failed") + client = await init_integration(hass, config_entry, [mock_diffuser("lot123")]) + client.hub.side_effect = RitualsGenieAuthenticationError + + freezer.tick(timedelta(minutes=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + + async def test_config_entry_unload(hass: HomeAssistant) -> None: """Test the Rituals Perfume Genie configuration entry setup and unloading.""" config_entry = mock_config_entry(unique_id="id_123_unload") diff --git a/tests/components/rituals_perfume_genie/test_number.py b/tests/components/rituals_perfume_genie/test_number.py index e851c7ae473c..1fd432fd20f0 100644 --- a/tests/components/rituals_perfume_genie/test_number.py +++ b/tests/components/rituals_perfume_genie/test_number.py @@ -50,9 +50,8 @@ async def test_set_number_value(hass: HomeAssistant) -> None: """Test setting the diffuser number entity value.""" config_entry = mock_config_entry(unique_id="number_set_value_test") diffuser = mock_diffuser_v1_battery_cartridge() - await init_integration(hass, config_entry, [diffuser]) + client = await init_integration(hass, config_entry, [diffuser]) await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) - diffuser.perfume_amount = 1 state = hass.states.get("number.genie_perfume_amount") assert state @@ -64,6 +63,15 @@ async def test_set_number_value(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "number.genie_perfume_amount", ATTR_VALUE: 1}, blocking=True, ) + + client.set_perfume_amount.assert_awaited_once_with(diffuser.hub_hash, 1) + + # The new value shows right away, without waiting for the next update. + state = hass.states.get("number.genie_perfume_amount") + assert state + assert state.state == "1" + + diffuser.perfume_amount = 1 await hass.services.async_call( HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index 10c9bcf037de..090ce0813d32 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -1,6 +1,7 @@ """Tests for the Rituals Perfume Genie select platform.""" import pytest +from ritualsgenie import RoomSize from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, @@ -10,6 +11,7 @@ from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_NEXT, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -49,9 +51,8 @@ async def test_select_option(hass: HomeAssistant) -> None: """Test selecting of a option.""" config_entry = mock_config_entry(unique_id="select_invalid_option_test") diffuser = mock_diffuser(hublot="lot123", room_size_square_meter=60) - await init_integration(hass, config_entry, [diffuser]) + client = await init_integration(hass, config_entry, [diffuser]) await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) - diffuser.room_size_square_meter = 30 state = hass.states.get("select.genie_room_size") assert state @@ -63,6 +64,12 @@ async def test_select_option(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "select.genie_room_size", ATTR_OPTION: "30"}, blocking=True, ) + + client.set_room_size_category.assert_awaited_once_with( + diffuser.hub_hash, RoomSize.MEDIUM + ) + + diffuser.room_size_square_meter = 30 await hass.services.async_call( HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -105,3 +112,27 @@ async def test_select_invalid_option(hass: HomeAssistant) -> None: state = hass.states.get("select.genie_room_size") assert state assert state.state == "60" + + +async def test_select_next_twice(hass: HomeAssistant) -> None: + """Test selecting the next option twice before an update moves on twice.""" + config_entry = mock_config_entry(unique_id="select_next_twice_test") + diffuser = mock_diffuser(hublot="lot123", room_size_square_meter=15) + client = await init_integration(hass, config_entry, [diffuser]) + + for _ in range(2): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_NEXT, + {ATTR_ENTITY_ID: "select.genie_room_size"}, + blocking=True, + ) + + assert [call.args[1] for call in client.set_room_size_category.await_args_list] == [ + RoomSize.MEDIUM, + RoomSize.LARGE, + ] + + state = hass.states.get("select.genie_room_size") + assert state + assert state.state == "60" diff --git a/tests/components/rituals_perfume_genie/test_switch.py b/tests/components/rituals_perfume_genie/test_switch.py index 6dd22c329376..98aec4ff4158 100644 --- a/tests/components/rituals_perfume_genie/test_switch.py +++ b/tests/components/rituals_perfume_genie/test_switch.py @@ -1,9 +1,5 @@ """Tests for the Rituals Perfume Genie switch platform.""" -import logging - -import pytest - from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -49,7 +45,7 @@ async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: """Test handling a coordinator update.""" config_entry = mock_config_entry(unique_id="switch_handle_coordinator_update_test") diffuser = mock_diffuser_v1_battery_cartridge() - await init_integration(hass, config_entry, [diffuser]) + client = await init_integration(hass, config_entry, [diffuser]) await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) coordinator = config_entry.runtime_data["lot123v1"] diffuser.is_on = False @@ -58,7 +54,7 @@ async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: assert state assert state.state == STATE_ON - call_count_before_update = diffuser.update_data.call_count + call_count_before_update = client.hub.call_count await hass.services.async_call( HOMEASSISTANT_DOMAIN, @@ -73,35 +69,30 @@ async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: assert state.state == STATE_OFF assert coordinator.last_update_success - assert diffuser.update_data.call_count == call_count_before_update + 1 + assert client.hub.call_count == call_count_before_update + 1 -async def test_device_info_sw_version_dict( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - caplog: pytest.LogCaptureFixture, +async def test_device_info( + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Test that sw_version is a string even when the library returns a dict.""" - diffuser = mock_diffuser( - hublot="lot123dict", - version={"id": 1, "title": "5.2-rc15", "icon": ""}, - ) - config_entry = mock_config_entry(unique_id="id_123_version_dict_test") - with caplog.at_level(logging.WARNING, logger="homeassistant.helpers.frame"): - await init_integration(hass, config_entry, [diffuser]) + """Test the device info, with the firmware as version.""" + diffuser = mock_diffuser(hublot="lot123device", version="5.4") + config_entry = mock_config_entry(unique_id="id_123_device_info_test") + await init_integration(hass, config_entry, [diffuser]) device_entry = device_registry.async_get_device_by_identifier( - ("rituals_perfume_genie", "lot123dict"), config_entry.entry_id + ("rituals_perfume_genie", "lot123device"), config_entry.entry_id ) assert device_entry - assert device_entry.sw_version == "5.2-rc15" - assert "non-string value" not in caplog.text + assert device_entry.name == "Genie" + assert device_entry.sw_version == "5.4" async def test_set_switch_state(hass: HomeAssistant) -> None: """Test changing the diffuser switch entity state.""" config_entry = mock_config_entry(unique_id="id_123_switch_set_state_test") - await init_integration(hass, config_entry, [mock_diffuser_v1_battery_cartridge()]) + diffuser = mock_diffuser_v1_battery_cartridge() + client = await init_integration(hass, config_entry, [diffuser]) state = hass.states.get("switch.genie") assert state @@ -117,6 +108,7 @@ async def test_set_switch_state(hass: HomeAssistant) -> None: state = hass.states.get("switch.genie") assert state assert state.state == STATE_OFF + client.turn_off.assert_awaited_once_with(diffuser.hub_hash) await hass.services.async_call( SWITCH_DOMAIN, @@ -128,3 +120,4 @@ async def test_set_switch_state(hass: HomeAssistant) -> None: state = hass.states.get("switch.genie") assert state assert state.state == STATE_ON + client.turn_on.assert_awaited_once_with(diffuser.hub_hash)