mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 22:38:02 -04:00
Migrate Rituals Perfume Genie to ritualsgenie (#184313)
This commit is contained in:
@@ -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()
|
||||
|
||||
Generated
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user