mirror of
https://github.com/home-assistant/core.git
synced 2025-12-09 09:28:11 +00:00
Compare commits
10 Commits
lovelace_d
...
whirlpool_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a11e3c3a59 | ||
|
|
1f68809cf9 | ||
|
|
66bddebca1 | ||
|
|
2280d779a8 | ||
|
|
ebc608845c | ||
|
|
5d13a41926 | ||
|
|
630b40fbba | ||
|
|
7fd440c4a0 | ||
|
|
54fdb98990 | ||
|
|
c0be1cc7f7 |
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -1358,8 +1358,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ring/ @sdb9696
|
||||
/homeassistant/components/risco/ @OnFreund
|
||||
/tests/components/risco/ @OnFreund
|
||||
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck
|
||||
/tests/components/rituals_perfume_genie/ @milanmeu @frenck
|
||||
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm
|
||||
/tests/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm
|
||||
/homeassistant/components/rmvtransport/ @cgtobi
|
||||
/tests/components/rmvtransport/ @cgtobi
|
||||
/homeassistant/components/roborock/ @Lash-L @allenporter
|
||||
|
||||
@@ -2,33 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohttp import ClientSession
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
HassJob,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_CONFIG_ENTRY
|
||||
from .coordinator import DuckDnsConfigEntry, DuckDnsUpdateCoordinator
|
||||
from .helpers import update_duckdns
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,17 +25,8 @@ ATTR_TXT = "txt"
|
||||
|
||||
DOMAIN = "duckdns"
|
||||
|
||||
INTERVAL = timedelta(minutes=5)
|
||||
BACKOFF_INTERVALS = (
|
||||
INTERVAL,
|
||||
timedelta(minutes=1),
|
||||
timedelta(minutes=5),
|
||||
timedelta(minutes=15),
|
||||
timedelta(minutes=30),
|
||||
)
|
||||
SERVICE_SET_TXT = "set_txt"
|
||||
|
||||
UPDATE_URL = "https://www.duckdns.org/update"
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -71,8 +51,6 @@ SERVICE_TXT_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
type DuckDnsConfigEntry = ConfigEntry
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Initialize the DuckDNS component."""
|
||||
@@ -99,21 +77,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
|
||||
"""Set up Duck DNS from a config entry."""
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
coordinator = DuckDnsUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
async def update_domain_interval(_now: datetime) -> bool:
|
||||
"""Update the DuckDNS entry."""
|
||||
return await _update_duckdns(
|
||||
session,
|
||||
entry.data[CONF_DOMAIN],
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval_backoff(
|
||||
hass, update_domain_interval, BACKOFF_INTERVALS
|
||||
)
|
||||
)
|
||||
# Add a dummy listener as we do not have regular entities
|
||||
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||
|
||||
return True
|
||||
|
||||
@@ -153,7 +122,7 @@ async def update_domain_service(call: ServiceCall) -> None:
|
||||
|
||||
session = async_get_clientsession(call.hass)
|
||||
|
||||
await _update_duckdns(
|
||||
await update_duckdns(
|
||||
session,
|
||||
entry.data[CONF_DOMAIN],
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
@@ -164,73 +133,3 @@ async def update_domain_service(call: ServiceCall) -> None:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
async def _update_duckdns(
|
||||
session: ClientSession,
|
||||
domain: str,
|
||||
token: str,
|
||||
*,
|
||||
txt: str | None | object = _SENTINEL,
|
||||
clear: bool = False,
|
||||
) -> bool:
|
||||
"""Update DuckDNS."""
|
||||
params = {"domains": domain, "token": token}
|
||||
|
||||
if txt is not _SENTINEL:
|
||||
if txt is None:
|
||||
# Pass in empty txt value to indicate it's clearing txt record
|
||||
params["txt"] = ""
|
||||
clear = True
|
||||
else:
|
||||
params["txt"] = cast(str, txt)
|
||||
|
||||
if clear:
|
||||
params["clear"] = "true"
|
||||
|
||||
resp = await session.get(UPDATE_URL, params=params)
|
||||
body = await resp.text()
|
||||
|
||||
if body != "OK":
|
||||
_LOGGER.warning("Updating DuckDNS domain failed: %s", domain)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def async_track_time_interval_backoff(
|
||||
hass: HomeAssistant,
|
||||
action: Callable[[datetime], Coroutine[Any, Any, bool]],
|
||||
intervals: Sequence[timedelta],
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Add a listener that fires repetitively at every timedelta interval."""
|
||||
remove: CALLBACK_TYPE | None = None
|
||||
failed = 0
|
||||
|
||||
async def interval_listener(now: datetime) -> None:
|
||||
"""Handle elapsed intervals with backoff."""
|
||||
nonlocal failed, remove
|
||||
try:
|
||||
failed += 1
|
||||
if await action(now):
|
||||
failed = 0
|
||||
finally:
|
||||
delay = intervals[failed] if failed < len(intervals) else intervals[-1]
|
||||
remove = async_call_later(
|
||||
hass, delay.total_seconds(), interval_listener_job
|
||||
)
|
||||
|
||||
interval_listener_job = HassJob(interval_listener, cancel_on_shutdown=True)
|
||||
hass.async_run_hass_job(interval_listener_job, dt_util.utcnow())
|
||||
|
||||
def remove_listener() -> None:
|
||||
"""Remove interval listener."""
|
||||
if remove:
|
||||
remove()
|
||||
|
||||
return remove_listener
|
||||
|
||||
@@ -16,8 +16,8 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from . import _update_duckdns
|
||||
from .const import DOMAIN
|
||||
from .helpers import update_duckdns
|
||||
from .issue import deprecate_yaml_issue
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -46,7 +46,7 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._async_abort_entries_match({CONF_DOMAIN: user_input[CONF_DOMAIN]})
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
if not await _update_duckdns(
|
||||
if not await update_duckdns(
|
||||
session,
|
||||
user_input[CONF_DOMAIN],
|
||||
user_input[CONF_ACCESS_TOKEN],
|
||||
@@ -93,7 +93,7 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
if not await _update_duckdns(
|
||||
if not await update_duckdns(
|
||||
session,
|
||||
entry.data[CONF_DOMAIN],
|
||||
user_input[CONF_ACCESS_TOKEN],
|
||||
|
||||
83
homeassistant/components/duckdns/coordinator.py
Normal file
83
homeassistant/components/duckdns/coordinator.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Coordinator for the Duck DNS integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
from .helpers import update_duckdns
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type DuckDnsConfigEntry = ConfigEntry[DuckDnsUpdateCoordinator]
|
||||
|
||||
INTERVAL = timedelta(minutes=5)
|
||||
BACKOFF_INTERVALS = (
|
||||
INTERVAL,
|
||||
timedelta(minutes=1),
|
||||
timedelta(minutes=5),
|
||||
timedelta(minutes=15),
|
||||
timedelta(minutes=30),
|
||||
)
|
||||
|
||||
|
||||
class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Duck DNS update coordinator."""
|
||||
|
||||
config_entry: DuckDnsConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: DuckDnsConfigEntry) -> None:
|
||||
"""Initialize the Duck DNS update coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=INTERVAL,
|
||||
)
|
||||
self.session = async_get_clientsession(hass)
|
||||
self.failed = 0
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update Duck DNS."""
|
||||
|
||||
retry_after = BACKOFF_INTERVALS[
|
||||
min(self.failed, len(BACKOFF_INTERVALS))
|
||||
].total_seconds()
|
||||
|
||||
try:
|
||||
if not await update_duckdns(
|
||||
self.session,
|
||||
self.config_entry.data[CONF_DOMAIN],
|
||||
self.config_entry.data[CONF_ACCESS_TOKEN],
|
||||
):
|
||||
self.failed += 1
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={
|
||||
CONF_DOMAIN: self.config_entry.data[CONF_DOMAIN],
|
||||
},
|
||||
retry_after=retry_after,
|
||||
)
|
||||
except ClientError as e:
|
||||
self.failed += 1
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
translation_placeholders={
|
||||
CONF_DOMAIN: self.config_entry.data[CONF_DOMAIN],
|
||||
},
|
||||
retry_after=retry_after,
|
||||
) from e
|
||||
self.failed = 0
|
||||
35
homeassistant/components/duckdns/helpers.py
Normal file
35
homeassistant/components/duckdns/helpers.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Helpers for Duck DNS integration."""
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
UPDATE_URL = "https://www.duckdns.org/update"
|
||||
|
||||
|
||||
async def update_duckdns(
|
||||
session: ClientSession,
|
||||
domain: str,
|
||||
token: str,
|
||||
*,
|
||||
txt: str | None | UndefinedType = UNDEFINED,
|
||||
clear: bool = False,
|
||||
) -> bool:
|
||||
"""Update DuckDNS."""
|
||||
params = {"domains": domain, "token": token}
|
||||
|
||||
if txt is not UNDEFINED:
|
||||
if txt is None:
|
||||
# Pass in empty txt value to indicate it's clearing txt record
|
||||
params["txt"] = ""
|
||||
clear = True
|
||||
else:
|
||||
params["txt"] = txt
|
||||
|
||||
if clear:
|
||||
params["clear"] = "true"
|
||||
|
||||
resp = await session.get(UPDATE_URL, params=params)
|
||||
body = await resp.text()
|
||||
|
||||
return body == "OK"
|
||||
@@ -32,11 +32,17 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_error": {
|
||||
"message": "Updating Duck DNS domain {domain} failed due to a connection error"
|
||||
},
|
||||
"entry_not_found": {
|
||||
"message": "Duck DNS integration entry not found"
|
||||
},
|
||||
"entry_not_selected": {
|
||||
"message": "Duck DNS integration entry not selected"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Updating Duck DNS domain {domain} failed"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -23,7 +23,7 @@ CONF_CHAT_MODEL = "chat_model"
|
||||
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
|
||||
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
|
||||
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
|
||||
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image-preview"
|
||||
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
RECOMMENDED_TEMPERATURE = 1.0
|
||||
CONF_TOP_P = "top_p"
|
||||
|
||||
@@ -7,11 +7,10 @@ in the Home Assistant Labs UI for users to enable or disable.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
from homeassistant.const import EVENT_LABS_UPDATED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.generated.labs import LABS_PREVIEW_FEATURES
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.storage import Store
|
||||
@@ -19,6 +18,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_custom_components
|
||||
|
||||
from .const import DOMAIN, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
|
||||
from .helpers import async_is_preview_feature_enabled, async_listen
|
||||
from .models import (
|
||||
EventLabsUpdatedData,
|
||||
LabPreviewFeature,
|
||||
@@ -135,55 +135,3 @@ async def _async_scan_all_preview_features(
|
||||
|
||||
_LOGGER.debug("Loaded %d total lab preview features", len(preview_features))
|
||||
return preview_features
|
||||
|
||||
|
||||
@callback
|
||||
def async_is_preview_feature_enabled(
|
||||
hass: HomeAssistant, domain: str, preview_feature: str
|
||||
) -> bool:
|
||||
"""Check if a lab preview feature is enabled.
|
||||
|
||||
Args:
|
||||
hass: HomeAssistant instance
|
||||
domain: Integration domain
|
||||
preview_feature: Preview feature name
|
||||
|
||||
Returns:
|
||||
True if the preview feature is enabled, False otherwise
|
||||
"""
|
||||
if LABS_DATA not in hass.data:
|
||||
return False
|
||||
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
return (domain, preview_feature) in labs_data.data.preview_feature_status
|
||||
|
||||
|
||||
@callback
|
||||
def async_listen(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
preview_feature: str,
|
||||
listener: Callable[[], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for changes to a specific preview feature.
|
||||
|
||||
Args:
|
||||
hass: HomeAssistant instance
|
||||
domain: Integration domain
|
||||
preview_feature: Preview feature name
|
||||
listener: Callback to invoke when the preview feature is toggled
|
||||
|
||||
Returns:
|
||||
Callable to unsubscribe from the listener
|
||||
"""
|
||||
|
||||
@callback
|
||||
def _async_feature_updated(event: Event[EventLabsUpdatedData]) -> None:
|
||||
"""Handle labs feature update event."""
|
||||
if (
|
||||
event.data["domain"] == domain
|
||||
and event.data["preview_feature"] == preview_feature
|
||||
):
|
||||
listener()
|
||||
|
||||
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)
|
||||
|
||||
63
homeassistant/components/labs/helpers.py
Normal file
63
homeassistant/components/labs/helpers.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Helper functions for the Home Assistant Labs integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.const import EVENT_LABS_UPDATED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
|
||||
from .const import LABS_DATA
|
||||
from .models import EventLabsUpdatedData
|
||||
|
||||
|
||||
@callback
|
||||
def async_is_preview_feature_enabled(
|
||||
hass: HomeAssistant, domain: str, preview_feature: str
|
||||
) -> bool:
|
||||
"""Check if a lab preview feature is enabled.
|
||||
|
||||
Args:
|
||||
hass: HomeAssistant instance
|
||||
domain: Integration domain
|
||||
preview_feature: Preview feature name
|
||||
|
||||
Returns:
|
||||
True if the preview feature is enabled, False otherwise
|
||||
"""
|
||||
if LABS_DATA not in hass.data:
|
||||
return False
|
||||
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
return (domain, preview_feature) in labs_data.data.preview_feature_status
|
||||
|
||||
|
||||
@callback
|
||||
def async_listen(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
preview_feature: str,
|
||||
listener: Callable[[], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for changes to a specific preview feature.
|
||||
|
||||
Args:
|
||||
hass: HomeAssistant instance
|
||||
domain: Integration domain
|
||||
preview_feature: Preview feature name
|
||||
listener: Callback to invoke when the preview feature is toggled
|
||||
|
||||
Returns:
|
||||
Callable to unsubscribe from the listener
|
||||
"""
|
||||
|
||||
@callback
|
||||
def _async_feature_updated(event: Event[EventLabsUpdatedData]) -> None:
|
||||
"""Handle labs feature update event."""
|
||||
if (
|
||||
event.data["domain"] == domain
|
||||
and event.data["preview_feature"] == preview_feature
|
||||
):
|
||||
listener()
|
||||
|
||||
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)
|
||||
@@ -12,6 +12,7 @@ from homeassistant.const import EVENT_LABS_UPDATED
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import LABS_DATA
|
||||
from .helpers import async_is_preview_feature_enabled, async_listen
|
||||
from .models import EventLabsUpdatedData
|
||||
|
||||
|
||||
@@ -20,6 +21,7 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
"""Set up the number websocket API."""
|
||||
websocket_api.async_register_command(hass, websocket_list_preview_features)
|
||||
websocket_api.async_register_command(hass, websocket_update_preview_feature)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_feature)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -108,3 +110,52 @@ async def websocket_update_preview_feature(
|
||||
hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "labs/subscribe",
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("preview_feature"): str,
|
||||
}
|
||||
)
|
||||
def websocket_subscribe_feature(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to a specific lab preview feature updates."""
|
||||
domain = msg["domain"]
|
||||
preview_feature_key = msg["preview_feature"]
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
|
||||
preview_feature_id = f"{domain}.{preview_feature_key}"
|
||||
|
||||
if preview_feature_id not in labs_data.preview_features:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_FOUND,
|
||||
f"Preview feature {preview_feature_id} not found",
|
||||
)
|
||||
return
|
||||
|
||||
preview_feature = labs_data.preview_features[preview_feature_id]
|
||||
|
||||
@callback
|
||||
def send_event() -> None:
|
||||
"""Send feature state to client."""
|
||||
enabled = async_is_preview_feature_enabled(hass, domain, preview_feature_key)
|
||||
connection.send_message(
|
||||
websocket_api.event_message(
|
||||
msg["id"],
|
||||
preview_feature.to_dict(enabled=enabled),
|
||||
)
|
||||
)
|
||||
|
||||
connection.subscriptions[msg["id"]] = async_listen(
|
||||
hass, domain, preview_feature_key, send_event
|
||||
)
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
send_event()
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
"""The Rituals Perfume Genie integration."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from pyrituals import Account, Diffuser
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from pyrituals import Account, AuthenticationException, Diffuser
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import ACCOUNT_HASH, DOMAIN, UPDATE_INTERVAL
|
||||
from .coordinator import RitualsDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.NUMBER,
|
||||
@@ -26,12 +29,38 @@ PLATFORMS = [
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Rituals Perfume Genie from a config entry."""
|
||||
# Initiate reauth for old config entries which don't have username / password in the entry data
|
||||
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(session=session, account_hash=entry.data[ACCOUNT_HASH])
|
||||
|
||||
account = Account(
|
||||
email=entry.data[CONF_EMAIL],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
|
||||
try:
|
||||
# Authenticate first so API token/cookies are available for subsequent calls
|
||||
await account.authenticate()
|
||||
account_devices = await account.get_devices()
|
||||
except aiohttp.ClientError as err:
|
||||
|
||||
except AuthenticationException as err:
|
||||
# Credentials invalid/expired -> raise AuthFailed to trigger reauth flow
|
||||
|
||||
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
|
||||
|
||||
# Migrate old unique_ids to the new format
|
||||
@@ -45,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Create a coordinator for each diffuser
|
||||
coordinators = {
|
||||
diffuser.hublot: RitualsDataUpdateCoordinator(
|
||||
hass, entry, diffuser, update_interval
|
||||
hass, entry, account, diffuser, update_interval
|
||||
)
|
||||
for diffuser in account_devices
|
||||
}
|
||||
@@ -106,3 +135,14 @@ def async_migrate_entities_unique_ids(
|
||||
registry_entry.entity_id,
|
||||
new_unique_id=f"{diffuser.hublot}-{new_unique_id}",
|
||||
)
|
||||
|
||||
|
||||
# Migration helpers for API v2
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate config entry to version 2: drop legacy ACCOUNT_HASH and bump version."""
|
||||
if entry.version < 2:
|
||||
data = dict(entry.data)
|
||||
data.pop(ACCOUNT_HASH, None)
|
||||
hass.config_entries.async_update_entry(entry, data=data, version=2)
|
||||
return True
|
||||
return True
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from aiohttp import ClientError
|
||||
from pyrituals import Account, AuthenticationException
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -13,9 +13,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import ACCOUNT_HASH, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -28,39 +26,88 @@ DATA_SCHEMA = vol.Schema(
|
||||
class RitualsPerfumeGenieConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Rituals Perfume Genie."""
|
||||
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA)
|
||||
|
||||
errors = {}
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
account = Account(user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session)
|
||||
|
||||
try:
|
||||
await account.authenticate()
|
||||
except ClientResponseError:
|
||||
_LOGGER.exception("Unexpected response")
|
||||
errors["base"] = "cannot_connect"
|
||||
except AuthenticationException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(account.email)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=account.email,
|
||||
data={ACCOUNT_HASH: account.account_hash},
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
account = Account(
|
||||
email=user_input[CONF_EMAIL],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
|
||||
try:
|
||||
await account.authenticate()
|
||||
except AuthenticationException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_EMAIL])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL],
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication with Rituals."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Form to log in again."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert reauth_entry.unique_id is not None
|
||||
|
||||
if user_input:
|
||||
session = async_get_clientsession(self.hass)
|
||||
account = Account(
|
||||
email=reauth_entry.unique_id,
|
||||
password=user_input[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
|
||||
try:
|
||||
await account.authenticate()
|
||||
except AuthenticationException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data={
|
||||
CONF_EMAIL: reauth_entry.unique_id,
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
reauth_entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import timedelta
|
||||
|
||||
DOMAIN = "rituals_perfume_genie"
|
||||
|
||||
# Old (API V1)
|
||||
ACCOUNT_HASH = "account_hash"
|
||||
|
||||
# The API provided by Rituals is currently rate limited to 30 requests
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyrituals import Diffuser
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from pyrituals import Account, AuthenticationException, Diffuser
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -23,10 +25,12 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
account: Account,
|
||||
diffuser: Diffuser,
|
||||
update_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize global Rituals Perfume Genie data updater."""
|
||||
self.account = account
|
||||
self.diffuser = diffuser
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -37,5 +41,36 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from Rituals."""
|
||||
await self.diffuser.update_data()
|
||||
"""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.
|
||||
"""
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"domain": "rituals_perfume_genie",
|
||||
"name": "Rituals Perfume Genie",
|
||||
"codeowners": ["@milanmeu", "@frenck"],
|
||||
"codeowners": ["@milanmeu", "@frenck", "@quebulm"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyrituals"],
|
||||
"requirements": ["pyrituals==0.0.6"]
|
||||
"requirements": ["pyrituals==0.0.7"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -9,6 +10,12 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"description": "Please enter the correct password."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
|
||||
@@ -44,6 +44,7 @@ from .entity import (
|
||||
)
|
||||
from .utils import (
|
||||
async_remove_orphaned_entities,
|
||||
async_remove_shelly_entity,
|
||||
format_ble_addr,
|
||||
get_blu_trv_device_info,
|
||||
get_device_entry_gen,
|
||||
@@ -80,6 +81,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action="trigger_reboot",
|
||||
supported=lambda coordinator: coordinator.sleep_period == 0,
|
||||
),
|
||||
ShellyButtonDescription[ShellyBlockCoordinator](
|
||||
key="self_test",
|
||||
@@ -197,7 +199,8 @@ async def async_setup_entry(
|
||||
"""Set up button entities."""
|
||||
entry_data = config_entry.runtime_data
|
||||
coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None
|
||||
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
|
||||
device_gen = get_device_entry_gen(config_entry)
|
||||
if device_gen in RPC_GENERATIONS:
|
||||
coordinator = entry_data.rpc
|
||||
else:
|
||||
coordinator = entry_data.block
|
||||
@@ -210,6 +213,12 @@ async def async_setup_entry(
|
||||
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
|
||||
)
|
||||
|
||||
# Remove the 'restart' button for sleeping devices as it was mistakenly
|
||||
# added in https://github.com/home-assistant/core/pull/154673
|
||||
entry_sleep_period = config_entry.data[CONF_SLEEP_PERIOD]
|
||||
if device_gen in RPC_GENERATIONS and entry_sleep_period:
|
||||
async_remove_shelly_entity(hass, BUTTON_PLATFORM, f"{coordinator.mac}-reboot")
|
||||
|
||||
entities: list[ShellyButton] = []
|
||||
|
||||
entities.extend(
|
||||
@@ -224,7 +233,7 @@ async def async_setup_entry(
|
||||
return
|
||||
|
||||
# add RPC buttons
|
||||
if config_entry.data[CONF_SLEEP_PERIOD]:
|
||||
if entry_sleep_period:
|
||||
async_setup_entry_rpc(
|
||||
hass,
|
||||
config_entry,
|
||||
|
||||
@@ -64,6 +64,12 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DESCRIPTION_PLACEHOLDERS: dict[str, str] = {
|
||||
"botfather_username": "@BotFather",
|
||||
"botfather_url": "https://t.me/botfather",
|
||||
"socks_url": "socks5://username:password@proxy_ip:proxy_port",
|
||||
}
|
||||
|
||||
STEP_USER_DATA_SCHEMA: vol.Schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PLATFORM): SelectSelector(
|
||||
@@ -310,10 +316,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow to create a new config entry for a Telegram bot."""
|
||||
|
||||
description_placeholders: dict[str, str] = {
|
||||
"botfather_username": "@BotFather",
|
||||
"botfather_url": "https://t.me/botfather",
|
||||
}
|
||||
description_placeholders: dict[str, str] = DESCRIPTION_PLACEHOLDERS.copy()
|
||||
if not user_input:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
@@ -552,13 +555,14 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
},
|
||||
),
|
||||
description_placeholders=DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get(
|
||||
CONF_PROXY_URL
|
||||
)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = DESCRIPTION_PLACEHOLDERS.copy()
|
||||
|
||||
user_input[CONF_API_KEY] = api_key
|
||||
bot_name = await self._validate_bot(
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"proxy_url": "Proxy URL"
|
||||
},
|
||||
"data_description": {
|
||||
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)"
|
||||
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n({socks_url})"
|
||||
},
|
||||
"name": "Advanced settings"
|
||||
}
|
||||
|
||||
@@ -244,9 +244,7 @@ OVEN_CAVITY_SENSORS: tuple[WhirlpoolOvenCavitySensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda oven, cavity: (
|
||||
temp if (temp := oven.get_temp(cavity)) != 0 else None
|
||||
),
|
||||
value_fn=lambda oven, cavity: oven.get_temp(cavity),
|
||||
),
|
||||
WhirlpoolOvenCavitySensorEntityDescription(
|
||||
key="oven_target_temperature",
|
||||
@@ -254,9 +252,7 @@ OVEN_CAVITY_SENSORS: tuple[WhirlpoolOvenCavitySensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda oven, cavity: (
|
||||
temp if (temp := oven.get_target_temp(cavity)) != 0 else None
|
||||
),
|
||||
value_fn=lambda oven, cavity: oven.get_target_temp(cavity),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1914,11 +1914,11 @@
|
||||
"title": "Zigbee network settings have changed"
|
||||
},
|
||||
"wrong_silabs_firmware_installed_nabucasa": {
|
||||
"description": "Your Zigbee adapter was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}).\n\nTo run your adapter exclusively with ZHA, you need to install the Zigbee firmware:\n - Go to Settings > System > Hardware, select the device and select Configure.\n - Select the 'Migrate Zigbee to a new adapter' option and follow the instructions.",
|
||||
"title": "Zigbee adapter with multiprotocol firmware detected"
|
||||
"description": "Your Zigbee adapter is currently in an incorrect state: {firmware_type}.\n\nThe device may have Thread or multiprotocol firmware installed, or it may be stuck in the bootloader. To resolve this, try to unplug the adapter temporarily.\n\nIf the issue persists and you need to install Zigbee firmware:\n - Go to Settings > System > Hardware, select the device and select Configure.\n - Select the 'Migrate Zigbee to a new adapter' option and follow the instructions.",
|
||||
"title": "Zigbee adapter in incorrect state"
|
||||
},
|
||||
"wrong_silabs_firmware_installed_other": {
|
||||
"description": "Your Zigbee adapter was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}).\n\nTo run your adapter exclusively with ZHA, you need to install Zigbee firmware. Follow your Zigbee adapter manufacturer's instructions for how to do this.",
|
||||
"description": "Your Zigbee adapter is currently in an incorrect state: {firmware_type}.\n\nThe device may have Thread or multiprotocol firmware installed, or it may be stuck in the bootloader. To resolve this, try to unplug the adapter temporarily.\n\nIf the issue persists and you need to install Zigbee firmware, follow your Zigbee adapter manufacturer's instructions for how to do this.",
|
||||
"title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]"
|
||||
}
|
||||
},
|
||||
|
||||
2
requirements_all.txt
generated
2
requirements_all.txt
generated
@@ -2351,7 +2351,7 @@ pyrepetierng==0.1.0
|
||||
pyrisco==0.6.7
|
||||
|
||||
# homeassistant.components.rituals_perfume_genie
|
||||
pyrituals==0.0.6
|
||||
pyrituals==0.0.7
|
||||
|
||||
# homeassistant.components.thread
|
||||
pyroute2==0.7.5
|
||||
|
||||
2
requirements_test_all.txt
generated
2
requirements_test_all.txt
generated
@@ -1980,7 +1980,7 @@ pyrate-limiter==3.9.0
|
||||
pyrisco==0.6.7
|
||||
|
||||
# homeassistant.components.rituals_perfume_genie
|
||||
pyrituals==0.0.6
|
||||
pyrituals==0.0.7
|
||||
|
||||
# homeassistant.components.thread
|
||||
pyroute2==0.7.5
|
||||
|
||||
@@ -43,7 +43,7 @@ def mock_update_duckdns() -> Generator[AsyncMock]:
|
||||
"""Mock _update_duckdns."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.duckdns.config_flow._update_duckdns",
|
||||
"homeassistant.components.duckdns.config_flow.update_duckdns",
|
||||
return_value=True,
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
@@ -5,15 +5,9 @@ import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.duckdns import (
|
||||
ATTR_TXT,
|
||||
BACKOFF_INTERVALS,
|
||||
DOMAIN,
|
||||
INTERVAL,
|
||||
SERVICE_SET_TXT,
|
||||
UPDATE_URL,
|
||||
async_track_time_interval_backoff,
|
||||
)
|
||||
from homeassistant.components.duckdns import ATTR_TXT, DOMAIN, SERVICE_SET_TXT
|
||||
from homeassistant.components.duckdns.coordinator import BACKOFF_INTERVALS
|
||||
from homeassistant.components.duckdns.helpers import UPDATE_URL
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.dt import utcnow
|
||||
@@ -73,12 +67,13 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -
|
||||
assert aioclient_mock.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.freeze_time
|
||||
async def test_setup_backoff(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup fails if first update fails."""
|
||||
"""Test update fails with backoffs and recovers."""
|
||||
aioclient_mock.get(
|
||||
UPDATE_URL,
|
||||
params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN},
|
||||
@@ -86,10 +81,10 @@ async def test_setup_backoff(
|
||||
)
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
assert aioclient_mock.call_count == 1
|
||||
|
||||
tme = utcnow()
|
||||
@@ -103,6 +98,17 @@ async def test_setup_backoff(
|
||||
|
||||
assert aioclient_mock.call_count == idx + 1
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.get(
|
||||
UPDATE_URL,
|
||||
params={"domains": TEST_SUBDOMAIN, "token": TEST_TOKEN},
|
||||
text="OK",
|
||||
)
|
||||
|
||||
async_fire_time_changed(hass, tme)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_duckdns")
|
||||
async def test_service_set_txt(
|
||||
@@ -147,67 +153,6 @@ async def test_service_clear_txt(
|
||||
assert aioclient_mock.call_count == 1
|
||||
|
||||
|
||||
async def test_async_track_time_interval_backoff(hass: HomeAssistant) -> None:
|
||||
"""Test setup fails if first update fails."""
|
||||
ret_val = False
|
||||
call_count = 0
|
||||
tme = None
|
||||
|
||||
async def _return(now):
|
||||
nonlocal call_count, ret_val, tme
|
||||
if tme is None:
|
||||
tme = now
|
||||
call_count += 1
|
||||
return ret_val
|
||||
|
||||
intervals = (
|
||||
INTERVAL,
|
||||
INTERVAL * 2,
|
||||
INTERVAL * 5,
|
||||
INTERVAL * 9,
|
||||
INTERVAL * 10,
|
||||
INTERVAL * 11,
|
||||
INTERVAL * 12,
|
||||
)
|
||||
|
||||
async_track_time_interval_backoff(hass, _return, intervals)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert call_count == 1
|
||||
|
||||
_LOGGER.debug("Backoff")
|
||||
for idx in range(1, len(intervals)):
|
||||
tme += intervals[idx]
|
||||
async_fire_time_changed(hass, tme + timedelta(seconds=0.1))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert call_count == idx + 1
|
||||
|
||||
_LOGGER.debug("Max backoff reached - intervals[-1]")
|
||||
for _idx in range(1, 10):
|
||||
tme += intervals[-1]
|
||||
async_fire_time_changed(hass, tme + timedelta(seconds=0.1))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert call_count == idx + 1 + _idx
|
||||
|
||||
_LOGGER.debug("Reset backoff")
|
||||
call_count = 0
|
||||
ret_val = True
|
||||
tme += intervals[-1]
|
||||
async_fire_time_changed(hass, tme + timedelta(seconds=0.1))
|
||||
await hass.async_block_till_done()
|
||||
assert call_count == 1
|
||||
|
||||
_LOGGER.debug("No backoff - intervals[0]")
|
||||
for _idx in range(2, 10):
|
||||
tme += intervals[0]
|
||||
async_fire_time_changed(hass, tme + timedelta(seconds=0.1))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert call_count == _idx
|
||||
|
||||
|
||||
async def test_load_unload(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
|
||||
@@ -695,3 +695,199 @@ async def test_websocket_backup_timeout_handling(
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "unknown_error"
|
||||
|
||||
|
||||
async def test_websocket_subscribe_feature(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test subscribing to a specific preview feature."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/subscribe",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
assert msg["result"] is None
|
||||
|
||||
# Initial state is sent as event
|
||||
event_msg = await client.receive_json()
|
||||
assert event_msg["type"] == "event"
|
||||
assert event_msg["event"] == {
|
||||
"preview_feature": "special_repair",
|
||||
"domain": "kitchen_sink",
|
||||
"enabled": False,
|
||||
"is_built_in": True,
|
||||
"feedback_url": ANY,
|
||||
"learn_more_url": ANY,
|
||||
"report_issue_url": ANY,
|
||||
}
|
||||
|
||||
|
||||
async def test_websocket_subscribe_feature_receives_updates(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test that subscription receives updates when feature is toggled."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/subscribe",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
}
|
||||
)
|
||||
subscribe_msg = await client.receive_json()
|
||||
assert subscribe_msg["success"]
|
||||
subscription_id = subscribe_msg["id"]
|
||||
|
||||
# Initial state event
|
||||
initial_event_msg = await client.receive_json()
|
||||
assert initial_event_msg["id"] == subscription_id
|
||||
assert initial_event_msg["type"] == "event"
|
||||
assert initial_event_msg["event"]["enabled"] is False
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Update event arrives before the update result
|
||||
event_msg = await client.receive_json()
|
||||
assert event_msg["id"] == subscription_id
|
||||
assert event_msg["type"] == "event"
|
||||
assert event_msg["event"] == {
|
||||
"preview_feature": "special_repair",
|
||||
"domain": "kitchen_sink",
|
||||
"enabled": True,
|
||||
"is_built_in": True,
|
||||
"feedback_url": ANY,
|
||||
"learn_more_url": ANY,
|
||||
"report_issue_url": ANY,
|
||||
}
|
||||
|
||||
update_msg = await client.receive_json()
|
||||
assert update_msg["success"]
|
||||
|
||||
|
||||
async def test_websocket_subscribe_nonexistent_feature(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test subscribing to a preview feature that doesn't exist."""
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/subscribe",
|
||||
"domain": "nonexistent",
|
||||
"preview_feature": "feature",
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "not_found"
|
||||
assert "not found" in msg["error"]["message"].lower()
|
||||
|
||||
|
||||
async def test_websocket_subscribe_does_not_require_admin(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_admin_user: MockUser,
|
||||
) -> None:
|
||||
"""Test that subscribe does not require admin privileges."""
|
||||
hass_admin_user.groups = []
|
||||
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/subscribe",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["success"]
|
||||
|
||||
# Consume initial state event
|
||||
await client.receive_json()
|
||||
|
||||
|
||||
async def test_websocket_subscribe_only_receives_subscribed_feature_updates(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test that subscription only receives updates for the subscribed feature."""
|
||||
hass.config.components.add("kitchen_sink")
|
||||
assert await async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/subscribe",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
}
|
||||
)
|
||||
subscribe_msg = await client.receive_json()
|
||||
assert subscribe_msg["success"]
|
||||
|
||||
# Consume initial state event
|
||||
await client.receive_json()
|
||||
|
||||
# Fire an event for a different feature
|
||||
hass.bus.async_fire(
|
||||
EVENT_LABS_UPDATED,
|
||||
{"domain": "other_domain", "preview_feature": "other_feature", "enabled": True},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "labs/update",
|
||||
"domain": "kitchen_sink",
|
||||
"preview_feature": "special_repair",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Event message arrives before the update result
|
||||
# Should only receive event for subscribed feature, not the other one
|
||||
event_msg = await client.receive_json()
|
||||
assert event_msg["type"] == "event"
|
||||
assert event_msg["event"]["domain"] == "kitchen_sink"
|
||||
assert event_msg["event"]["preview_feature"] == "special_repair"
|
||||
|
||||
update_msg = await client.receive_json()
|
||||
assert update_msg["success"]
|
||||
|
||||
@@ -4,8 +4,9 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN
|
||||
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
|
||||
@@ -17,7 +18,11 @@ def mock_config_entry(unique_id: str, entry_id: str = "an_entry_id") -> MockConf
|
||||
domain=DOMAIN,
|
||||
title="name@example.com",
|
||||
unique_id=unique_id,
|
||||
data={ACCOUNT_HASH: "an_account_hash"},
|
||||
data={
|
||||
CONF_EMAIL: "test@rituals.com",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
version=2,
|
||||
entry_id=entry_id,
|
||||
)
|
||||
|
||||
@@ -90,13 +95,15 @@ async def init_integration(
|
||||
"""Initialize the Rituals Perfume Genie integration with the given Config Entry and Diffuser list."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
with patch(
|
||||
"homeassistant.components.rituals_perfume_genie.Account.get_devices",
|
||||
return_value=mock_diffusers,
|
||||
):
|
||||
"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)
|
||||
|
||||
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.entry_id in hass.data[DOMAIN]
|
||||
assert hass.data[DOMAIN]
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
64
tests/components/rituals_perfume_genie/conftest.py
Normal file
64
tests/components/rituals_perfume_genie/conftest.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Fixtures for Rituals Perfume Genie tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.rituals_perfume_genie import ACCOUNT_HASH, DOMAIN
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
|
||||
from .const import TEST_EMAIL, TEST_PASSWORD
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.rituals_perfume_genie.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rituals_account() -> Generator[AsyncMock]:
|
||||
"""Mock Rituals Account."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.rituals_perfume_genie.config_flow.Account",
|
||||
autospec=True,
|
||||
) as mock_account_cls,
|
||||
patch(
|
||||
"homeassistant.components.rituals_perfume_genie.Account",
|
||||
new=mock_account_cls,
|
||||
),
|
||||
):
|
||||
mock_account = mock_account_cls.return_value
|
||||
yield mock_account
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Mock Rituals Account."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=TEST_EMAIL,
|
||||
data={CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD},
|
||||
title=TEST_EMAIL,
|
||||
version=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def old_mock_config_entry() -> MockConfigEntry:
|
||||
"""Mock Rituals Account."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=TEST_EMAIL,
|
||||
data={ACCOUNT_HASH: "old_hash_should_be_removed"},
|
||||
title=TEST_EMAIL,
|
||||
version=1,
|
||||
)
|
||||
4
tests/components/rituals_perfume_genie/const.py
Normal file
4
tests/components/rituals_perfume_genie/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Constants for rituals_perfume_genie tests."""
|
||||
|
||||
TEST_EMAIL = "test@rituals.com"
|
||||
TEST_PASSWORD = "test-password"
|
||||
@@ -1,126 +1,213 @@
|
||||
"""Test the Rituals Perfume Genie config flow."""
|
||||
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from aiohttp import ClientError
|
||||
from pyrituals import AuthenticationException
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN
|
||||
from homeassistant.components.rituals_perfume_genie.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
TEST_EMAIL = "rituals@example.com"
|
||||
VALID_PASSWORD = "passw0rd"
|
||||
WRONG_PASSWORD = "wrong-passw0rd"
|
||||
from .const import TEST_EMAIL, TEST_PASSWORD
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
def _mock_account(*_):
|
||||
account = MagicMock()
|
||||
account.authenticate = AsyncMock()
|
||||
account.account_hash = "any"
|
||||
account.email = TEST_EMAIL
|
||||
return account
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistant) -> None:
|
||||
"""Test we get the form."""
|
||||
async def test_user_flow_success(
|
||||
hass: HomeAssistant, mock_rituals_account: AsyncMock, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test successful user flow setup."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] is None
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.rituals_perfume_genie.config_flow.Account",
|
||||
side_effect=_mock_account,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.rituals_perfume_genie.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_EMAIL: TEST_EMAIL,
|
||||
CONF_PASSWORD: VALID_PASSWORD,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_EMAIL: TEST_EMAIL,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == TEST_EMAIL
|
||||
assert isinstance(result2["data"][ACCOUNT_HASH], str)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_EMAIL
|
||||
assert result["data"] == {
|
||||
CONF_EMAIL: TEST_EMAIL,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
}
|
||||
assert result["result"].unique_id == TEST_EMAIL
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
|
||||
"""Test we handle invalid auth."""
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(AuthenticationException, "invalid_auth"),
|
||||
(ClientError, "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_user_flow_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_rituals_account: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
exception: Exception,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test user flow with different errors."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
mock_rituals_account.authenticate.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_EMAIL: TEST_EMAIL,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate",
|
||||
side_effect=AuthenticationException,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_EMAIL: TEST_EMAIL,
|
||||
CONF_PASSWORD: WRONG_PASSWORD,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": "invalid_auth"}
|
||||
mock_rituals_account.authenticate.side_effect = None
|
||||
|
||||
|
||||
async def test_form_auth_exception(hass: HomeAssistant) -> None:
|
||||
"""Test we handle auth exception."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_EMAIL: TEST_EMAIL,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate",
|
||||
side_effect=Exception,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_EMAIL: TEST_EMAIL,
|
||||
CONF_PASSWORD: VALID_PASSWORD,
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": "unknown"}
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
|
||||
"""Test we handle cannot connect error."""
|
||||
async def test_duplicate_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_rituals_account: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test user flow with invalid credentials."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate",
|
||||
side_effect=ClientResponseError(
|
||||
None, None, status=HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
),
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_EMAIL: TEST_EMAIL,
|
||||
CONF_PASSWORD: VALID_PASSWORD,
|
||||
},
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_EMAIL: TEST_EMAIL,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_reauth_flow_success(
|
||||
hass: HomeAssistant,
|
||||
mock_rituals_account: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test successful reauth flow (updating credentials)."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_PASSWORD: "new_correct_password"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
assert mock_config_entry.data[CONF_PASSWORD] == "new_correct_password"
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(AuthenticationException, "invalid_auth"),
|
||||
(ClientError, "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_reauth_flow_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_rituals_account: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
exception: Exception,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test reauth flow with different errors."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
mock_rituals_account.authenticate.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_PASSWORD: "new_correct_password"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
mock_rituals_account.authenticate.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_PASSWORD: "new_correct_password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data[CONF_PASSWORD] == "new_correct_password"
|
||||
|
||||
|
||||
async def test_reauth_migrated_entry(
|
||||
hass: HomeAssistant, mock_rituals_account: AsyncMock, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test successful reauth flow (updating credentials)."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=TEST_EMAIL,
|
||||
data={},
|
||||
title=TEST_EMAIL,
|
||||
version=2,
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_PASSWORD: "new_correct_password"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
assert mock_config_entry.data == {
|
||||
CONF_EMAIL: TEST_EMAIL,
|
||||
CONF_PASSWORD: "new_correct_password",
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Tests for the Rituals Perfume Genie integration."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.components.rituals_perfume_genie.const import DOMAIN
|
||||
from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -16,17 +16,39 @@ from .common import (
|
||||
mock_diffuser_v1_battery_cartridge,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
async def test_config_entry_not_ready(hass: HomeAssistant) -> None:
|
||||
|
||||
async def test_migration_v1_to_v2(
|
||||
hass: HomeAssistant,
|
||||
mock_rituals_account: AsyncMock,
|
||||
old_mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test migration from V1 (account_hash) to V2 (credentials)."""
|
||||
old_mock_config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(old_mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert old_mock_config_entry.version == 2
|
||||
assert ACCOUNT_HASH not in old_mock_config_entry.data
|
||||
assert old_mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert len(hass.config_entries.flow.async_progress()) == 1
|
||||
|
||||
|
||||
async def test_config_entry_not_ready(
|
||||
hass: HomeAssistant,
|
||||
mock_rituals_account: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the Rituals configuration entry setup if connection to Rituals is missing."""
|
||||
config_entry = mock_config_entry(unique_id="id_123_not_ready")
|
||||
config_entry.add_to_hass(hass)
|
||||
with patch(
|
||||
"homeassistant.components.rituals_perfume_genie.Account.get_devices",
|
||||
side_effect=aiohttp.ClientError,
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
mock_rituals_account.get_devices.side_effect = aiohttp.ClientError
|
||||
|
||||
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_RETRY
|
||||
|
||||
|
||||
async def test_config_entry_unload(hass: HomeAssistant) -> None:
|
||||
|
||||
@@ -554,3 +554,30 @@ async def test_wall_display_screen_buttons(
|
||||
blocking=True,
|
||||
)
|
||||
mock_rpc_device.wall_display_set_screen.assert_called_once_with(value=value)
|
||||
|
||||
|
||||
async def test_rpc_remove_restart_button_for_sleeping_devices(
|
||||
hass: HomeAssistant,
|
||||
mock_rpc_device: Mock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
device_registry: DeviceRegistry,
|
||||
entity_registry: EntityRegistry,
|
||||
) -> None:
|
||||
"""Test RPC remove restart button for sleeping devices."""
|
||||
config_entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True)
|
||||
device_entry = register_device(device_registry, config_entry)
|
||||
entity_id = register_entity(
|
||||
hass,
|
||||
BUTTON_DOMAIN,
|
||||
"test_name_restart",
|
||||
"reboot",
|
||||
config_entry,
|
||||
device_id=device_entry.id,
|
||||
)
|
||||
|
||||
assert entity_registry.async_get(entity_id) is not None
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entity_registry.async_get(entity_id) is None
|
||||
|
||||
Reference in New Issue
Block a user