Migrate Rituals Perfume Genie to ritualsgenie (#184313)

This commit is contained in:
Franck Nijhof
2026-10-05 14:16:57 +02:00
committed by GitHub
parent 2135026f71
commit 19dca278ce
22 changed files with 530 additions and 586 deletions
@@ -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}",
)
@@ -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)
@@ -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(
@@ -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)
@@ -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()
]
}
@@ -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
@@ -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"]
}
@@ -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()
@@ -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()
@@ -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)
@@ -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()
+3 -3
View File
@@ -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
+119 -63
View File
@@ -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
@@ -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
@@ -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
}
]
}
}
@@ -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
}
]
}
}
@@ -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,
}),
}),
}),
]),
@@ -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(
@@ -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")
@@ -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,
@@ -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"
@@ -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)