Skip to content

Commit 630b40f

Browse files
quebulmjoostlekCopilot
authored
Fix Rituals Perfume Genie (home-assistant#151537)
Co-authored-by: Joostlek <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 7fd440c commit 630b40f

File tree

14 files changed

+476
-162
lines changed

14 files changed

+476
-162
lines changed

CODEOWNERS

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

homeassistant/components/rituals_perfume_genie/__init__.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
"""The Rituals Perfume Genie integration."""
22

33
import asyncio
4+
import logging
45

5-
import aiohttp
6-
from pyrituals import Account, Diffuser
6+
from aiohttp import ClientError, ClientResponseError
7+
from pyrituals import Account, AuthenticationException, Diffuser
78

89
from homeassistant.config_entries import ConfigEntry
9-
from homeassistant.const import Platform
10+
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
1011
from homeassistant.core import HomeAssistant, callback
11-
from homeassistant.exceptions import ConfigEntryNotReady
12+
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
1213
from homeassistant.helpers import entity_registry as er
1314
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1415

1516
from .const import ACCOUNT_HASH, DOMAIN, UPDATE_INTERVAL
1617
from .coordinator import RitualsDataUpdateCoordinator
1718

19+
_LOGGER = logging.getLogger(__name__)
20+
1821
PLATFORMS = [
1922
Platform.BINARY_SENSOR,
2023
Platform.NUMBER,
@@ -26,12 +29,38 @@
2629

2730
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
2831
"""Set up Rituals Perfume Genie from a config entry."""
32+
# Initiate reauth for old config entries which don't have username / password in the entry data
33+
if CONF_EMAIL not in entry.data or CONF_PASSWORD not in entry.data:
34+
raise ConfigEntryAuthFailed("Missing credentials")
35+
2936
session = async_get_clientsession(hass)
30-
account = Account(session=session, account_hash=entry.data[ACCOUNT_HASH])
37+
38+
account = Account(
39+
email=entry.data[CONF_EMAIL],
40+
password=entry.data[CONF_PASSWORD],
41+
session=session,
42+
)
3143

3244
try:
45+
# Authenticate first so API token/cookies are available for subsequent calls
46+
await account.authenticate()
3347
account_devices = await account.get_devices()
34-
except aiohttp.ClientError as err:
48+
49+
except AuthenticationException as err:
50+
# Credentials invalid/expired -> raise AuthFailed to trigger reauth flow
51+
52+
raise ConfigEntryAuthFailed(err) from err
53+
54+
except ClientResponseError as err:
55+
_LOGGER.debug(
56+
"HTTP error during Rituals setup: status=%s, url=%s, headers=%s",
57+
err.status,
58+
err.request_info,
59+
dict(err.headers or {}),
60+
)
61+
raise ConfigEntryNotReady from err
62+
63+
except ClientError as err:
3564
raise ConfigEntryNotReady from err
3665

3766
# Migrate old unique_ids to the new format
@@ -45,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
4574
# Create a coordinator for each diffuser
4675
coordinators = {
4776
diffuser.hublot: RitualsDataUpdateCoordinator(
48-
hass, entry, diffuser, update_interval
77+
hass, entry, account, diffuser, update_interval
4978
)
5079
for diffuser in account_devices
5180
}
@@ -106,3 +135,14 @@ def async_migrate_entities_unique_ids(
106135
registry_entry.entity_id,
107136
new_unique_id=f"{diffuser.hublot}-{new_unique_id}",
108137
)
138+
139+
140+
# Migration helpers for API v2
141+
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
142+
"""Migrate config entry to version 2: drop legacy ACCOUNT_HASH and bump version."""
143+
if entry.version < 2:
144+
data = dict(entry.data)
145+
data.pop(ACCOUNT_HASH, None)
146+
hass.config_entries.async_update_entry(entry, data=data, version=2)
147+
return True
148+
return True

homeassistant/components/rituals_perfume_genie/config_flow.py

Lines changed: 79 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,18 @@
22

33
from __future__ import annotations
44

5-
import logging
6-
from typing import Any
5+
from collections.abc import Mapping
6+
from typing import TYPE_CHECKING, Any
77

8-
from aiohttp import ClientResponseError
8+
from aiohttp import ClientError
99
from pyrituals import Account, AuthenticationException
1010
import voluptuous as vol
1111

1212
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
1313
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
1414
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1515

16-
from .const import ACCOUNT_HASH, DOMAIN
17-
18-
_LOGGER = logging.getLogger(__name__)
16+
from .const import DOMAIN
1917

2018
DATA_SCHEMA = vol.Schema(
2119
{
@@ -28,39 +26,88 @@
2826
class RitualsPerfumeGenieConfigFlow(ConfigFlow, domain=DOMAIN):
2927
"""Handle a config flow for Rituals Perfume Genie."""
3028

31-
VERSION = 1
29+
VERSION = 2
3230

3331
async def async_step_user(
3432
self, user_input: dict[str, Any] | None = None
3533
) -> ConfigFlowResult:
3634
"""Handle the initial step."""
37-
if user_input is None:
38-
return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA)
39-
40-
errors = {}
41-
42-
session = async_get_clientsession(self.hass)
43-
account = Account(user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session)
44-
45-
try:
46-
await account.authenticate()
47-
except ClientResponseError:
48-
_LOGGER.exception("Unexpected response")
49-
errors["base"] = "cannot_connect"
50-
except AuthenticationException:
51-
errors["base"] = "invalid_auth"
52-
except Exception:
53-
_LOGGER.exception("Unexpected exception")
54-
errors["base"] = "unknown"
55-
else:
56-
await self.async_set_unique_id(account.email)
57-
self._abort_if_unique_id_configured()
58-
59-
return self.async_create_entry(
60-
title=account.email,
61-
data={ACCOUNT_HASH: account.account_hash},
35+
errors: dict[str, str] = {}
36+
if user_input is not None:
37+
session = async_get_clientsession(self.hass)
38+
account = Account(
39+
email=user_input[CONF_EMAIL],
40+
password=user_input[CONF_PASSWORD],
41+
session=session,
6242
)
6343

44+
try:
45+
await account.authenticate()
46+
except AuthenticationException:
47+
errors["base"] = "invalid_auth"
48+
except ClientError:
49+
errors["base"] = "cannot_connect"
50+
else:
51+
await self.async_set_unique_id(user_input[CONF_EMAIL])
52+
self._abort_if_unique_id_configured()
53+
return self.async_create_entry(
54+
title=user_input[CONF_EMAIL],
55+
data=user_input,
56+
)
57+
6458
return self.async_show_form(
6559
step_id="user", data_schema=DATA_SCHEMA, errors=errors
6660
)
61+
62+
async def async_step_reauth(
63+
self, entry_data: Mapping[str, Any]
64+
) -> ConfigFlowResult:
65+
"""Handle re-authentication with Rituals."""
66+
return await self.async_step_reauth_confirm()
67+
68+
async def async_step_reauth_confirm(
69+
self, user_input: dict[str, Any] | None = None
70+
) -> ConfigFlowResult:
71+
"""Form to log in again."""
72+
errors: dict[str, str] = {}
73+
74+
reauth_entry = self._get_reauth_entry()
75+
76+
if TYPE_CHECKING:
77+
assert reauth_entry.unique_id is not None
78+
79+
if user_input:
80+
session = async_get_clientsession(self.hass)
81+
account = Account(
82+
email=reauth_entry.unique_id,
83+
password=user_input[CONF_PASSWORD],
84+
session=session,
85+
)
86+
87+
try:
88+
await account.authenticate()
89+
except AuthenticationException:
90+
errors["base"] = "invalid_auth"
91+
except ClientError:
92+
errors["base"] = "cannot_connect"
93+
else:
94+
return self.async_update_reload_and_abort(
95+
reauth_entry,
96+
data={
97+
CONF_EMAIL: reauth_entry.unique_id,
98+
CONF_PASSWORD: user_input[CONF_PASSWORD],
99+
},
100+
)
101+
102+
return self.async_show_form(
103+
step_id="reauth_confirm",
104+
data_schema=self.add_suggested_values_to_schema(
105+
vol.Schema(
106+
{
107+
vol.Required(CONF_PASSWORD): str,
108+
}
109+
),
110+
reauth_entry.data,
111+
),
112+
errors=errors,
113+
)

homeassistant/components/rituals_perfume_genie/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
DOMAIN = "rituals_perfume_genie"
66

7+
# Old (API V1)
78
ACCOUNT_HASH = "account_hash"
89

910
# The API provided by Rituals is currently rate limited to 30 requests

homeassistant/components/rituals_perfume_genie/coordinator.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from datetime import timedelta
44
import logging
55

6-
from pyrituals import Diffuser
6+
from aiohttp import ClientError, ClientResponseError
7+
from pyrituals import Account, AuthenticationException, Diffuser
78

89
from homeassistant.config_entries import ConfigEntry
910
from homeassistant.core import HomeAssistant
10-
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
11+
from homeassistant.exceptions import ConfigEntryAuthFailed
12+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1113

1214
from .const import DOMAIN
1315

@@ -23,10 +25,12 @@ def __init__(
2325
self,
2426
hass: HomeAssistant,
2527
config_entry: ConfigEntry,
28+
account: Account,
2629
diffuser: Diffuser,
2730
update_interval: timedelta,
2831
) -> None:
2932
"""Initialize global Rituals Perfume Genie data updater."""
33+
self.account = account
3034
self.diffuser = diffuser
3135
super().__init__(
3236
hass,
@@ -37,5 +41,36 @@ def __init__(
3741
)
3842

3943
async def _async_update_data(self) -> None:
40-
"""Fetch data from Rituals."""
41-
await self.diffuser.update_data()
44+
"""Fetch data from Rituals, with one silent re-auth on 401.
45+
46+
If silent re-auth also fails, raise ConfigEntryAuthFailed to trigger reauth flow.
47+
Other HTTP/network errors are wrapped in UpdateFailed so HA can retry.
48+
"""
49+
try:
50+
await self.diffuser.update_data()
51+
except (AuthenticationException, ClientResponseError) as err:
52+
# Treat 401/403 like AuthenticationException → one silent re-auth, single retry
53+
if isinstance(err, ClientResponseError) and (status := err.status) not in (
54+
401,
55+
403,
56+
):
57+
# Non-auth HTTP error → let HA retry
58+
raise UpdateFailed(f"HTTP {status}") from err
59+
60+
self.logger.debug(
61+
"Auth issue detected (%r). Attempting silent re-auth.", err
62+
)
63+
try:
64+
await self.account.authenticate()
65+
await self.diffuser.update_data()
66+
except AuthenticationException as err2:
67+
# Credentials invalid → trigger HA reauth
68+
raise ConfigEntryAuthFailed from err2
69+
except ClientResponseError as err2:
70+
# Still HTTP auth errors after refresh → trigger HA reauth
71+
if err2.status in (401, 403):
72+
raise ConfigEntryAuthFailed from err2
73+
raise UpdateFailed(f"HTTP {err2.status}") from err2
74+
except ClientError as err:
75+
# Network issues (timeouts, DNS, etc.)
76+
raise UpdateFailed(f"Network error: {err!r}") from err
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"domain": "rituals_perfume_genie",
33
"name": "Rituals Perfume Genie",
4-
"codeowners": ["@milanmeu", "@frenck"],
4+
"codeowners": ["@milanmeu", "@frenck", "@quebulm"],
55
"config_flow": true,
66
"documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie",
77
"iot_class": "cloud_polling",
88
"loggers": ["pyrituals"],
9-
"requirements": ["pyrituals==0.0.6"]
9+
"requirements": ["pyrituals==0.0.7"]
1010
}

homeassistant/components/rituals_perfume_genie/strings.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
{
22
"config": {
33
"abort": {
4-
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
4+
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
5+
"reauth_successful": "Re-authentication was successful"
56
},
67
"error": {
78
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
89
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
910
"unknown": "[%key:common::config_flow::error::unknown%]"
1011
},
1112
"step": {
13+
"reauth_confirm": {
14+
"data": {
15+
"password": "[%key:common::config_flow::data::password%]"
16+
},
17+
"description": "Please enter the correct password."
18+
},
1219
"user": {
1320
"data": {
1421
"email": "[%key:common::config_flow::data::email%]",

requirements_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

requirements_test_all.txt

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)