mirror of
https://github.com/home-assistant/core.git
synced 2026-03-19 01:11:58 +01:00
Compare commits
1 Commits
dev
...
remove-get
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
695f3a3f99 |
@@ -173,7 +173,6 @@ homeassistant.components.dnsip.*
|
||||
homeassistant.components.doorbird.*
|
||||
homeassistant.components.dormakaba_dkey.*
|
||||
homeassistant.components.downloader.*
|
||||
homeassistant.components.dropbox.*
|
||||
homeassistant.components.droplet.*
|
||||
homeassistant.components.dsmr.*
|
||||
homeassistant.components.duckdns.*
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -397,8 +397,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dremel_3d_printer/ @tkdrob
|
||||
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||
/homeassistant/components/dropbox/ @bdr99
|
||||
/tests/components/dropbox/ @bdr99
|
||||
/homeassistant/components/droplet/ @sarahseidman
|
||||
/tests/components/droplet/ @sarahseidman
|
||||
/homeassistant/components/dsmr/ @Robbie1221
|
||||
|
||||
@@ -120,7 +120,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="timeout",
|
||||
)
|
||||
self.login_task = None
|
||||
del self.login_task
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_reauth(
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/actron_air",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["actron-neo-api==0.4.1"]
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -135,7 +135,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"motion",
|
||||
"occupancy",
|
||||
"person",
|
||||
"schedule",
|
||||
"siren",
|
||||
"switch",
|
||||
"vacuum",
|
||||
|
||||
@@ -183,122 +183,90 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
existing_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is None:
|
||||
# Preserve existing values as defaults
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self._build_credentials_schema(existing_entry.data),
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=existing_entry.data.get(
|
||||
CONF_PASSKEY, vol.UNDEFINED
|
||||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=existing_entry.data.get(
|
||||
CONF_USERNAME, vol.UNDEFINED
|
||||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Merge existing data with user input for validation
|
||||
validate_data = {**existing_entry.data, **user_input}
|
||||
errors = await self._async_validate_credentials(validate_data)
|
||||
# Combine existing data with the user's new input for validation.
|
||||
# This correctly handles adding, changing, and clearing credentials.
|
||||
config_data = existing_entry.data.copy()
|
||||
config_data.update(user_input)
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self._build_credentials_schema(user_input),
|
||||
errors=errors,
|
||||
)
|
||||
self.host = config_data[CONF_HOST]
|
||||
self.port = config_data[CONF_PORT]
|
||||
self.passkey = config_data.get(CONF_PASSKEY)
|
||||
self.username = config_data.get(CONF_USERNAME)
|
||||
self.password = config_data.get(CONF_PASSWORD)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry, data_updates=user_input, reason="reauth_successful"
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration flow."""
|
||||
existing_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self._build_connection_schema(existing_entry.data),
|
||||
)
|
||||
|
||||
# Merge existing data with user input for validation
|
||||
validate_data = {**existing_entry.data, **user_input}
|
||||
errors = await self._async_validate_credentials(validate_data)
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self._build_connection_schema(user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# Prevent reconfiguring to a different physical device
|
||||
# it gets the unique ID from the device info when it validates credentials
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry,
|
||||
data_updates=user_input,
|
||||
reason="reconfigure_successful",
|
||||
)
|
||||
|
||||
async def _async_validate_credentials(self, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate connection credentials and return errors dict."""
|
||||
self.host = data[CONF_HOST]
|
||||
self.port = data.get(CONF_PORT, DEFAULT_PORT)
|
||||
self.passkey = data.get(CONF_PASSKEY)
|
||||
self.username = data.get(CONF_USERNAME)
|
||||
self.password = data.get(CONF_PASSWORD)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
await self._get_bsblan_info(raise_on_progress=False, is_reauth=True)
|
||||
except BSBLANAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors={"base": "invalid_auth"},
|
||||
)
|
||||
except BSBLANError:
|
||||
errors["base"] = "cannot_connect"
|
||||
return errors
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors={"base": "cannot_connect"},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _build_credentials_schema(self, defaults: Mapping[str, Any]) -> vol.Schema:
|
||||
"""Build schema for credentials-only forms (reauth)."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=defaults.get(CONF_USERNAME) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
@callback
|
||||
def _build_connection_schema(self, defaults: Mapping[str, Any]) -> vol.Schema:
|
||||
"""Build schema for full connection forms (user and reconfigure)."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_HOST,
|
||||
default=defaults.get(CONF_HOST, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PORT,
|
||||
default=defaults.get(CONF_PORT, DEFAULT_PORT),
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=defaults.get(CONF_USERNAME) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
# Update only the fields that were provided by the user
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry, data_updates=user_input, reason="reauth_successful"
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -306,9 +274,32 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self, errors: dict | None = None, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the setup form to the user."""
|
||||
# Preserve user input if provided, otherwise use defaults
|
||||
defaults = user_input or {}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self._build_connection_schema(user_input or {}),
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=defaults.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=defaults.get(CONF_PASSWORD, vol.UNDEFINED),
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device you are trying to reconfigure is not the same as the one previously configured."
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -41,24 +39,6 @@
|
||||
"description": "The BSB-LAN integration needs to re-authenticate with {name}",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::bsblan::config::step::user::data_description::host%]",
|
||||
"passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]",
|
||||
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
|
||||
"port": "[%key:component::bsblan::config::step::user::data_description::port%]",
|
||||
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "Update connection settings for your BSB-LAN device.",
|
||||
"title": "Reconfigure BSB-LAN"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""The Dropbox integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_dropbox_api import (
|
||||
DropboxAPIClient,
|
||||
DropboxAuthException,
|
||||
DropboxUnknownException,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from .auth import DropboxConfigEntryAuth
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
type DropboxConfigEntry = ConfigEntry[DropboxAPIClient]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
|
||||
"""Set up Dropbox from a config entry."""
|
||||
try:
|
||||
oauth2_implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
oauth2_session = OAuth2Session(hass, entry, oauth2_implementation)
|
||||
|
||||
auth = DropboxConfigEntryAuth(
|
||||
aiohttp_client.async_get_clientsession(hass), oauth2_session
|
||||
)
|
||||
|
||||
client = DropboxAPIClient(auth)
|
||||
|
||||
try:
|
||||
await client.get_account_info()
|
||||
except DropboxAuthException as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (DropboxUnknownException, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
entry.runtime_data = client
|
||||
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Application credentials platform for the Dropbox integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import ClientCredential
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
AbstractOAuth2Implementation,
|
||||
LocalOAuth2ImplementationWithPkce,
|
||||
)
|
||||
|
||||
from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN
|
||||
|
||||
|
||||
async def async_get_auth_implementation(
|
||||
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
|
||||
) -> AbstractOAuth2Implementation:
|
||||
"""Return custom auth implementation."""
|
||||
return DropboxOAuth2Implementation(
|
||||
hass,
|
||||
auth_domain,
|
||||
credential.client_id,
|
||||
OAUTH2_AUTHORIZE,
|
||||
OAUTH2_TOKEN,
|
||||
credential.client_secret,
|
||||
)
|
||||
|
||||
|
||||
class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
|
||||
"""Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters."""
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
data: dict = {
|
||||
"token_access_type": "offline",
|
||||
"scope": " ".join(OAUTH2_SCOPES),
|
||||
}
|
||||
data.update(super().extra_authorize_data)
|
||||
return data
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Authentication for Dropbox."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from python_dropbox_api import Auth
|
||||
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
|
||||
|
||||
class DropboxConfigEntryAuth(Auth):
|
||||
"""Provide Dropbox authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
oauth_session: OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize DropboxConfigEntryAuth."""
|
||||
super().__init__(websession)
|
||||
self._oauth_session = oauth_session
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
|
||||
return cast(str, self._oauth_session.token["access_token"])
|
||||
|
||||
|
||||
class DropboxConfigFlowAuth(Auth):
|
||||
"""Provide authentication tied to a fixed token for the config flow."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
token: str,
|
||||
) -> None:
|
||||
"""Initialize DropboxConfigFlowAuth."""
|
||||
super().__init__(websession)
|
||||
self._token = token
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return the fixed access token."""
|
||||
return self._token
|
||||
@@ -1,230 +0,0 @@
|
||||
"""Backup platform for the Dropbox integration."""
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from python_dropbox_api import (
|
||||
DropboxAPIClient,
|
||||
DropboxAuthException,
|
||||
DropboxFileOrFolderNotFoundException,
|
||||
DropboxUnknownException,
|
||||
)
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import DropboxConfigEntry
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
"""Return the suggested filenames for the backup and metadata."""
|
||||
base_name = suggested_filename(backup).rsplit(".", 1)[0]
|
||||
return f"{base_name}.tar", f"{base_name}.metadata.json"
|
||||
|
||||
|
||||
async def _async_string_iterator(content: str) -> AsyncIterator[bytes]:
|
||||
"""Yield a string as a single bytes chunk."""
|
||||
yield content.encode()
|
||||
|
||||
|
||||
def handle_backup_errors[_R, **P](
|
||||
func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]],
|
||||
) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]:
|
||||
"""Handle backup errors."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(
|
||||
self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs
|
||||
) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except DropboxFileOrFolderNotFoundException as err:
|
||||
raise BackupNotFound(
|
||||
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
|
||||
) from err
|
||||
except DropboxAuthException as err:
|
||||
self._entry.async_start_reauth(self._hass)
|
||||
raise BackupAgentError("Authentication error") from err
|
||||
except DropboxUnknownException as err:
|
||||
_LOGGER.error(
|
||||
"Error during %s: %s",
|
||||
func.__name__,
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Full error: %s", err, exc_info=True)
|
||||
raise BackupAgentError(
|
||||
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
hass: HomeAssistant,
|
||||
**kwargs: Any,
|
||||
) -> list[BackupAgent]:
|
||||
"""Return a list of backup agents."""
|
||||
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
return [DropboxBackupAgent(hass, entry) for entry in entries]
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_agents_listener(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
listener: Callable[[], None],
|
||||
**kwargs: Any,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a listener to be called when agents are added or removed.
|
||||
|
||||
:return: A function to unregister the listener.
|
||||
"""
|
||||
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove the listener."""
|
||||
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
|
||||
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
|
||||
del hass.data[DATA_BACKUP_AGENT_LISTENERS]
|
||||
|
||||
return remove_listener
|
||||
|
||||
|
||||
class DropboxBackupAgent(BackupAgent):
|
||||
"""Backup agent for the Dropbox integration."""
|
||||
|
||||
domain = DOMAIN
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None:
|
||||
"""Initialize the backup agent."""
|
||||
super().__init__()
|
||||
self._hass = hass
|
||||
self._entry = entry
|
||||
self.name = entry.title
|
||||
assert entry.unique_id
|
||||
self.unique_id = entry.unique_id
|
||||
self._api: DropboxAPIClient = entry.runtime_data
|
||||
|
||||
async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]:
|
||||
"""Get backups and their corresponding file names."""
|
||||
files = await self._api.list_folder("")
|
||||
|
||||
tar_files = {f.name for f in files if f.name.endswith(".tar")}
|
||||
metadata_files = [f for f in files if f.name.endswith(".metadata.json")]
|
||||
|
||||
backups: list[tuple[AgentBackup, str]] = []
|
||||
for metadata_file in metadata_files:
|
||||
tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar"
|
||||
if tar_name not in tar_files:
|
||||
_LOGGER.warning(
|
||||
"Found metadata file '%s' without matching backup file",
|
||||
metadata_file.name,
|
||||
)
|
||||
continue
|
||||
|
||||
metadata_stream = self._api.download_file(f"/{metadata_file.name}")
|
||||
raw = b"".join([chunk async for chunk in metadata_stream])
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
backup = AgentBackup.from_dict(data)
|
||||
except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err:
|
||||
_LOGGER.warning(
|
||||
"Skipping invalid metadata file '%s': %s",
|
||||
metadata_file.name,
|
||||
err,
|
||||
)
|
||||
continue
|
||||
backups.append((backup, tar_name))
|
||||
|
||||
return backups
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_upload_backup(
|
||||
self,
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
backup_filename, metadata_filename = _suggested_filenames(backup)
|
||||
backup_path = f"/{backup_filename}"
|
||||
metadata_path = f"/{metadata_filename}"
|
||||
|
||||
file_stream = await open_stream()
|
||||
await self._api.upload_file(backup_path, file_stream)
|
||||
|
||||
metadata_stream = _async_string_iterator(json.dumps(backup.as_dict()))
|
||||
|
||||
try:
|
||||
await self._api.upload_file(metadata_path, metadata_stream)
|
||||
except (
|
||||
DropboxAuthException,
|
||||
DropboxUnknownException,
|
||||
):
|
||||
await self._api.delete_file(backup_path)
|
||||
raise
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
return [backup for backup, _ in await self._async_get_backups()]
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file."""
|
||||
backups = await self._async_get_backups()
|
||||
for backup, filename in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
return self._api.download_file(f"/{filename}")
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup:
|
||||
"""Return a backup."""
|
||||
backups = await self._async_get_backups()
|
||||
|
||||
for backup, _ in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
return backup
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_delete_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file."""
|
||||
backups = await self._async_get_backups()
|
||||
for backup, tar_filename in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json"
|
||||
await self._api.delete_file(f"/{tar_filename}")
|
||||
await self._api.delete_file(f"/{metadata_filename}")
|
||||
return
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Config flow for Dropbox."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from python_dropbox_api import DropboxAPIClient
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .auth import DropboxConfigFlowAuth
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Config flow to handle Dropbox OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow, or update existing entry."""
|
||||
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
|
||||
|
||||
auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token)
|
||||
|
||||
client = DropboxAPIClient(auth)
|
||||
account_info = await client.get_account_info()
|
||||
|
||||
await self.async_set_unique_id(account_info.account_id)
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=data
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=account_info.email, data=data)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Constants for the Dropbox integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN = "dropbox"
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize"
|
||||
OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token"
|
||||
OAUTH2_SCOPES = [
|
||||
"account_info.read",
|
||||
"files.content.read",
|
||||
"files.content.write",
|
||||
]
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"domain": "dropbox",
|
||||
"name": "Dropbox",
|
||||
"after_dependencies": ["backup"],
|
||||
"codeowners": ["@bdr99"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dropbox",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-dropbox-api==0.1.3"]
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register any actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: Integration does not poll.
|
||||
brands: done
|
||||
common-modules:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities or coordinators.
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register any actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register any actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration does not have any configuration parameters.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: Integration does not make any entity updates.
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
diagnostics:
|
||||
status: exempt
|
||||
comment: Integration does not have any data to diagnose.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration is a service.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Integration is a service.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: Integration does not update any data.
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: Integration only provides backup functionality.
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: Integration does not support any devices.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Integration does not use any devices.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration does not have any repairs.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Integration does not have any devices.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"wrong_account": "Wrong account: Please authenticate with the correct account."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "The Dropbox integration needs to re-authenticate your account.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ class FullyButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Fully Kiosk Browser button description."""
|
||||
|
||||
press_action: Callable[[FullyKiosk], Any]
|
||||
refresh_after_press: bool = True
|
||||
|
||||
|
||||
BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
|
||||
@@ -69,13 +68,6 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda fully: fully.clearCache(),
|
||||
),
|
||||
FullyButtonEntityDescription(
|
||||
key="triggerMotion",
|
||||
translation_key="trigger_motion",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda fully: fully.triggerMotion(),
|
||||
refresh_after_press=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -110,5 +102,4 @@ class FullyButtonEntity(FullyKioskEntity, ButtonEntity):
|
||||
async def async_press(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
await self.entity_description.press_action(self.coordinator.fully)
|
||||
if self.entity_description.refresh_after_press:
|
||||
await self.coordinator.async_refresh()
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@@ -88,9 +88,6 @@
|
||||
},
|
||||
"to_foreground": {
|
||||
"name": "Bring to foreground"
|
||||
},
|
||||
"trigger_motion": {
|
||||
"name": "Trigger motion activity"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -12,7 +12,6 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
@@ -31,17 +30,11 @@ _PLATFORMS = (Platform.SENSOR,)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool:
|
||||
"""Set up Google Drive from a config entry."""
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
auth = AsyncConfigEntryAuth(
|
||||
async_get_clientsession(hass),
|
||||
OAuth2Session(hass, entry, implementation),
|
||||
OAuth2Session(
|
||||
hass, entry, await async_get_config_entry_implementation(hass, entry)
|
||||
),
|
||||
)
|
||||
|
||||
# Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not
|
||||
@@ -53,11 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry)
|
||||
try:
|
||||
folder_id, _ = await client.async_create_ha_root_folder_if_not_exists()
|
||||
except GoogleDriveApiError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_get_folder",
|
||||
translation_placeholders={"folder": "Home Assistant"},
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
|
||||
@@ -22,8 +22,6 @@ from homeassistant.exceptions import (
|
||||
)
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600
|
||||
_UPLOAD_MAX_RETRIES = 20
|
||||
|
||||
@@ -63,21 +61,14 @@ class AsyncConfigEntryAuth(AbstractAuth):
|
||||
):
|
||||
if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_not_valid",
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from ex
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from ex
|
||||
raise ConfigEntryNotReady from ex
|
||||
if hasattr(ex, "status") and ex.status == 400:
|
||||
self._oauth_session.config_entry.async_start_reauth(
|
||||
self._oauth_session.hass
|
||||
)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from ex
|
||||
raise HomeAssistantError(ex) from ex
|
||||
return str(self._oauth_session.token[CONF_ACCESS_TOKEN])
|
||||
|
||||
|
||||
|
||||
@@ -8,11 +8,7 @@ from typing import Any, cast
|
||||
|
||||
from google_drive_api.exceptions import GoogleDriveApiError
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -48,12 +44,6 @@ class OAuth2FlowHandler(
|
||||
"prompt": "consent",
|
||||
}
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reconfiguration flow."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
@@ -91,16 +81,13 @@ class OAuth2FlowHandler(
|
||||
|
||||
await self.async_set_unique_id(email_address)
|
||||
|
||||
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
|
||||
if self.source == SOURCE_REAUTH:
|
||||
entry = self._get_reauth_entry()
|
||||
else:
|
||||
entry = self._get_reconfigure_entry()
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self._abort_if_unique_id_mismatch(
|
||||
reason="wrong_account",
|
||||
description_placeholders={"email": cast(str, entry.unique_id)},
|
||||
description_placeholders={"email": cast(str, reauth_entry.unique_id)},
|
||||
)
|
||||
return self.async_update_reload_and_abort(entry, data=data)
|
||||
return self.async_update_reload_and_abort(reauth_entry, data=data)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ rules:
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
@@ -64,8 +66,12 @@ rules:
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: No configuration options.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repairs.
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"wrong_account": "Wrong account: Please authenticate with {email}."
|
||||
@@ -63,22 +62,5 @@
|
||||
"name": "Used storage in Drive Trash"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed"
|
||||
},
|
||||
"authentication_not_valid": {
|
||||
"message": "OAuth session is not valid, reauthentication required"
|
||||
},
|
||||
"failed_to_get_folder": {
|
||||
"message": "Failed to get {folder} folder"
|
||||
},
|
||||
"invalid_response_google_drive_error": {
|
||||
"message": "Invalid response from Google Drive: {error}"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ from homeassistant.helpers.update_coordinator import (
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar(
|
||||
@@ -99,13 +97,7 @@ class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
|
||||
self.subentry.title,
|
||||
err,
|
||||
)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
|
||||
|
||||
|
||||
class GoogleWeatherCurrentConditionsCoordinator(
|
||||
|
||||
@@ -66,7 +66,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
|
||||
@@ -98,10 +98,5 @@
|
||||
"name": "Wind gust speed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_error": {
|
||||
"message": "Error fetching weather data: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,6 @@ from .coordinator import (
|
||||
get_core_stats,
|
||||
get_host_info,
|
||||
get_info,
|
||||
get_issues_info,
|
||||
get_network_info,
|
||||
get_os_info,
|
||||
get_store,
|
||||
@@ -158,7 +157,6 @@ __all__ = [
|
||||
"get_core_stats",
|
||||
"get_host_info",
|
||||
"get_info",
|
||||
"get_issues_info",
|
||||
"get_network_info",
|
||||
"get_os_info",
|
||||
"get_store",
|
||||
|
||||
@@ -92,7 +92,6 @@ ISSUE_KEYS_FOR_REPAIRS = {
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
|
||||
"issue_system_ntp_sync_failed",
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -177,19 +177,6 @@
|
||||
},
|
||||
"title": "Multiple data disks detected"
|
||||
},
|
||||
"issue_system_ntp_sync_failed": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"apply_suggestion_fail": "Could not re-enable NTP. Check the Supervisor logs for more details."
|
||||
},
|
||||
"step": {
|
||||
"system_enable_ntp": {
|
||||
"description": "The device could not contact its configured time servers (NTP). Using a secondary online time check, we detected that the system clock was more than 1 hour incorrect. The time has been corrected and the NTP service was temporarily disabled so the correction could be applied. To keep the system time accurate, we recommend fixing the issue preventing access to the NTP servers.\n\nCheck the **Host logs** to investigate why NTP servers could not be reached. Once resolved, select **Submit** to re-enable the NTP service."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Time synchronization issue detected"
|
||||
},
|
||||
"issue_system_reboot_required": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["ohme==1.7.1"]
|
||||
"requirements": ["ohme==1.7.0"]
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
)
|
||||
self._connected: bool = False
|
||||
self._current_devices: set[str] = set()
|
||||
self._firmware_list: dict[str, str | None] = {}
|
||||
self._stored_devices: set[str] = set()
|
||||
self.new_devices: set[str] = set()
|
||||
|
||||
@@ -130,7 +129,6 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
) from err
|
||||
|
||||
self._add_remove_devices(data)
|
||||
self._update_device_firmware(data)
|
||||
return data
|
||||
|
||||
def _add_remove_devices(self, data: dict[str, GwEntityData]) -> None:
|
||||
@@ -140,9 +138,6 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
# 'new_devices' contains all devices present in 'data' at init ('self._current_devices' is empty)
|
||||
# this is required for the proper initialization of all the present platform entities.
|
||||
self.new_devices = set_of_data - self._current_devices
|
||||
for device_id in self.new_devices:
|
||||
self._firmware_list.setdefault(device_id, data[device_id].get("firmware"))
|
||||
|
||||
current_devices = (
|
||||
self._stored_devices if not self._current_devices else self._current_devices
|
||||
)
|
||||
@@ -154,52 +149,21 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
"""Clean registries when removed devices found."""
|
||||
device_reg = dr.async_get(self.hass)
|
||||
for device_id in removed_devices:
|
||||
if (
|
||||
device_entry := device_reg.async_get_device({(DOMAIN, device_id)})
|
||||
) is not None:
|
||||
device_reg.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
LOGGER.debug(
|
||||
"%s %s %s removed from device_registry",
|
||||
device_entry = device_reg.async_get_device({(DOMAIN, device_id)})
|
||||
if device_entry is None:
|
||||
LOGGER.warning(
|
||||
"Failed to remove %s device/zone %s, not present in device_registry",
|
||||
DOMAIN,
|
||||
device_entry.model,
|
||||
device_id,
|
||||
)
|
||||
continue # pragma: no cover
|
||||
|
||||
self._firmware_list.pop(device_id, None)
|
||||
|
||||
def _update_device_firmware(self, data: dict[str, GwEntityData]) -> None:
|
||||
"""Detect firmware changes and update the device registry."""
|
||||
for device_id, device in data.items():
|
||||
# Only update firmware when the key is present and not None, to avoid
|
||||
# wiping stored firmware on partial or transient updates.
|
||||
if "firmware" not in device:
|
||||
continue
|
||||
new_firmware = device.get("firmware")
|
||||
if new_firmware is None:
|
||||
continue
|
||||
if (
|
||||
device_id in self._firmware_list
|
||||
and new_firmware != self._firmware_list[device_id]
|
||||
):
|
||||
updated = self._update_firmware_in_dr(device_id, new_firmware)
|
||||
if updated:
|
||||
self._firmware_list[device_id] = new_firmware
|
||||
|
||||
def _update_firmware_in_dr(self, device_id: str, firmware: str | None) -> bool:
|
||||
"""Update device sw_version in device_registry."""
|
||||
device_reg = dr.async_get(self.hass)
|
||||
if (
|
||||
device_entry := device_reg.async_get_device({(DOMAIN, device_id)})
|
||||
) is not None:
|
||||
device_reg.async_update_device(device_entry.id, sw_version=firmware)
|
||||
device_reg.async_update_device(
|
||||
device_entry.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Firmware in device_registry updated for %s %s %s",
|
||||
"%s %s %s removed from device_registry",
|
||||
DOMAIN,
|
||||
device_entry.model,
|
||||
device_id,
|
||||
)
|
||||
return True
|
||||
|
||||
return False # pragma: no cover
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Provides conditions for schedules."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
|
||||
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the schedule conditions."""
|
||||
return CONDITIONS
|
||||
@@ -1,17 +0,0 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
domain: schedule
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_off: *condition_common
|
||||
is_on: *condition_common
|
||||
@@ -1,12 +1,4 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_off": {
|
||||
"condition": "mdi:calendar-blank"
|
||||
},
|
||||
"is_on": {
|
||||
"condition": "mdi:calendar-clock"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_schedule": {
|
||||
"service": "mdi:calendar-export"
|
||||
|
||||
@@ -1,32 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted schedules.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted schedules to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_off": {
|
||||
"description": "Tests if one or more schedule blocks are currently not active.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::schedule::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::schedule::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Schedule is off"
|
||||
},
|
||||
"is_on": {
|
||||
"description": "Tests if one or more schedule blocks are currently active.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::schedule::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::schedule::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Schedule is on"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::schedule::title%]",
|
||||
@@ -49,12 +25,6 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -3,18 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pysmartthings import Attribute, Capability, Category, Command, SmartThings
|
||||
from pysmartthings import Capability, Command, SmartThings
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FullDevice, SmartThingsConfigEntry
|
||||
from .const import DOMAIN, MAIN
|
||||
from .const import MAIN
|
||||
from .entity import SmartThingsEntity
|
||||
|
||||
|
||||
@@ -24,11 +22,7 @@ class SmartThingsButtonDescription(ButtonEntityDescription):
|
||||
|
||||
key: Capability
|
||||
command: Command
|
||||
command_identifier: str | None = None
|
||||
components: list[str] | None = None
|
||||
argument: int | str | list[Any] | dict[str, Any] | None = None
|
||||
requires_remote_control_status: bool = False
|
||||
requires_dishwasher_machine_state: set[str] | None = None
|
||||
|
||||
|
||||
CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = {
|
||||
@@ -59,50 +53,6 @@ CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] =
|
||||
}
|
||||
|
||||
|
||||
DISHWASHER_OPERATION_COMMANDS_TO_BUTTONS: dict[
|
||||
Command | str, SmartThingsButtonDescription
|
||||
] = {
|
||||
Command.CANCEL: SmartThingsButtonDescription(
|
||||
key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION,
|
||||
translation_key="cancel",
|
||||
command_identifier="drain",
|
||||
command=Command.CANCEL,
|
||||
argument=[True],
|
||||
requires_remote_control_status=True,
|
||||
),
|
||||
Command.PAUSE: SmartThingsButtonDescription(
|
||||
key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION,
|
||||
translation_key="pause",
|
||||
command=Command.PAUSE,
|
||||
requires_remote_control_status=True,
|
||||
requires_dishwasher_machine_state={"run"},
|
||||
),
|
||||
Command.RESUME: SmartThingsButtonDescription(
|
||||
key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION,
|
||||
translation_key="resume",
|
||||
command=Command.RESUME,
|
||||
requires_remote_control_status=True,
|
||||
requires_dishwasher_machine_state={"pause"},
|
||||
),
|
||||
Command.START: SmartThingsButtonDescription(
|
||||
key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION,
|
||||
translation_key="start",
|
||||
command=Command.START,
|
||||
requires_remote_control_status=True,
|
||||
requires_dishwasher_machine_state={"stop"},
|
||||
),
|
||||
}
|
||||
|
||||
DISHWASHER_CANCEL_AND_DRAIN_BUTTON = SmartThingsButtonDescription(
|
||||
key=Capability.CUSTOM_SUPPORTED_OPTIONS,
|
||||
translation_key="cancel_and_drain",
|
||||
command_identifier="89",
|
||||
command=Command.SET_COURSE,
|
||||
argument="89",
|
||||
requires_remote_control_status=True,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SmartThingsConfigEntry,
|
||||
@@ -110,41 +60,13 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Add button entities for a config entry."""
|
||||
entry_data = entry.runtime_data
|
||||
entities: list[SmartThingsEntity] = []
|
||||
entities.extend(
|
||||
SmartThingsButtonEntity(
|
||||
entry_data.client, device, description, Capability(capability), component
|
||||
)
|
||||
async_add_entities(
|
||||
SmartThingsButtonEntity(entry_data.client, device, description, component)
|
||||
for capability, description in CAPABILITIES_TO_BUTTONS.items()
|
||||
for device in entry_data.devices.values()
|
||||
for component in description.components or [MAIN]
|
||||
if component in device.status and capability in device.status[component]
|
||||
)
|
||||
entities.extend(
|
||||
SmartThingsButtonEntity(
|
||||
entry_data.client,
|
||||
device,
|
||||
description,
|
||||
Capability.SAMSUNG_CE_DISHWASHER_OPERATION,
|
||||
)
|
||||
for device in entry_data.devices.values()
|
||||
if Capability.SAMSUNG_CE_DISHWASHER_OPERATION in device.status[MAIN]
|
||||
for description in DISHWASHER_OPERATION_COMMANDS_TO_BUTTONS.values()
|
||||
)
|
||||
entities.extend(
|
||||
SmartThingsButtonEntity(
|
||||
entry_data.client,
|
||||
device,
|
||||
DISHWASHER_CANCEL_AND_DRAIN_BUTTON,
|
||||
Capability.CUSTOM_SUPPORTED_OPTIONS,
|
||||
)
|
||||
for device in entry_data.devices.values()
|
||||
if (
|
||||
device.device.components[MAIN].manufacturer_category == Category.DISHWASHER
|
||||
and Capability.CUSTOM_SUPPORTED_OPTIONS in device.status[MAIN]
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity):
|
||||
@@ -157,53 +79,16 @@ class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity):
|
||||
client: SmartThings,
|
||||
device: FullDevice,
|
||||
entity_description: SmartThingsButtonDescription,
|
||||
capability: Capability,
|
||||
component: str = MAIN,
|
||||
component: str,
|
||||
) -> None:
|
||||
"""Initialize the instance."""
|
||||
capabilities = set()
|
||||
if entity_description.requires_remote_control_status:
|
||||
capabilities.add(Capability.REMOTE_CONTROL_STATUS)
|
||||
if entity_description.requires_dishwasher_machine_state:
|
||||
capabilities.add(Capability.DISHWASHER_OPERATING_STATE)
|
||||
super().__init__(client, device, capabilities)
|
||||
super().__init__(client, device, set(), component=component)
|
||||
self.entity_description = entity_description
|
||||
self.button_capability = capability
|
||||
self._attr_unique_id = f"{device.device.device_id}_{component}_{entity_description.key}_{entity_description.command}"
|
||||
if entity_description.command_identifier is not None:
|
||||
self._attr_unique_id += f"_{entity_description.command_identifier}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
self._validate_before_execute()
|
||||
await self.execute_device_command(
|
||||
self.button_capability,
|
||||
self.entity_description.key,
|
||||
self.entity_description.command,
|
||||
self.entity_description.argument,
|
||||
)
|
||||
|
||||
def _validate_before_execute(self) -> None:
|
||||
"""Validate that the command can be executed."""
|
||||
if (
|
||||
self.entity_description.requires_remote_control_status
|
||||
and self.get_attribute_value(
|
||||
Capability.REMOTE_CONTROL_STATUS, Attribute.REMOTE_CONTROL_ENABLED
|
||||
)
|
||||
== "false"
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="remote_control_status"
|
||||
)
|
||||
if (
|
||||
self.entity_description.requires_dishwasher_machine_state
|
||||
and self.get_attribute_value(
|
||||
Capability.DISHWASHER_OPERATING_STATE, Attribute.MACHINE_STATE
|
||||
)
|
||||
not in self.entity_description.requires_dishwasher_machine_state
|
||||
):
|
||||
state_list = " or ".join(
|
||||
self.entity_description.requires_dishwasher_machine_state
|
||||
)
|
||||
raise ServiceValidationError(
|
||||
f"Can only be updated when dishwasher machine state is {state_list}"
|
||||
)
|
||||
|
||||
@@ -27,27 +27,12 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"cancel": {
|
||||
"default": "mdi:stop"
|
||||
},
|
||||
"cancel_and_drain": {
|
||||
"default": "mdi:stop"
|
||||
},
|
||||
"pause": {
|
||||
"default": "mdi:pause"
|
||||
},
|
||||
"reset_hepa_filter": {
|
||||
"default": "mdi:air-filter"
|
||||
},
|
||||
"reset_water_filter": {
|
||||
"default": "mdi:reload"
|
||||
},
|
||||
"resume": {
|
||||
"default": "mdi:play"
|
||||
},
|
||||
"start": {
|
||||
"default": "mdi:play"
|
||||
},
|
||||
"stop": {
|
||||
"default": "mdi:stop"
|
||||
}
|
||||
|
||||
@@ -93,15 +93,6 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"cancel": {
|
||||
"name": "Cancel"
|
||||
},
|
||||
"cancel_and_drain": {
|
||||
"name": "Cancel and drain"
|
||||
},
|
||||
"pause": {
|
||||
"name": "[%key:common::action::pause%]"
|
||||
},
|
||||
"reset_hepa_filter": {
|
||||
"name": "Reset HEPA filter"
|
||||
},
|
||||
@@ -111,12 +102,6 @@
|
||||
"reset_water_filter": {
|
||||
"name": "Reset water filter"
|
||||
},
|
||||
"resume": {
|
||||
"name": "Resume"
|
||||
},
|
||||
"start": {
|
||||
"name": "[%key:common::action::start%]"
|
||||
},
|
||||
"stop": {
|
||||
"name": "[%key:common::action::stop%]"
|
||||
}
|
||||
@@ -1024,9 +1009,6 @@
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"remote_control_status": {
|
||||
"message": "Can only be changed when remote control is enabled"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
ENTITY_ID_FORMAT,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
@@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import DOMAIN, WaterFurnaceConfigEntry
|
||||
from .coordinator import WaterFurnaceCoordinator
|
||||
@@ -176,6 +178,10 @@ class WaterFurnaceSensor(CoordinatorEntity[WaterFurnaceCoordinator], SensorEntit
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
|
||||
# This ensures that the sensors are isolated per waterfurnace unit
|
||||
self.entity_id = ENTITY_ID_FORMAT.format(
|
||||
f"wf_{slugify(coordinator.unit)}_{slugify(description.key)}"
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.unit}_{description.key}"
|
||||
|
||||
device_info = DeviceInfo(
|
||||
|
||||
@@ -214,7 +214,6 @@ class WithingsWebhookManager:
|
||||
"""Manager that manages the Withings webhooks."""
|
||||
|
||||
_webhooks_registered = False
|
||||
_webhook_url_invalid = False
|
||||
_register_lock = asyncio.Lock()
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: WithingsConfigEntry) -> None:
|
||||
@@ -261,20 +260,16 @@ class WithingsWebhookManager:
|
||||
)
|
||||
url = URL(webhook_url)
|
||||
if url.scheme != "https":
|
||||
if not self._webhook_url_invalid:
|
||||
LOGGER.warning(
|
||||
"Webhook not registered - HTTPS is required. "
|
||||
"See https://www.home-assistant.io/integrations/withings/#webhook-requirements"
|
||||
)
|
||||
self._webhook_url_invalid = True
|
||||
LOGGER.warning(
|
||||
"Webhook not registered - HTTPS is required. "
|
||||
"See https://www.home-assistant.io/integrations/withings/#webhook-requirements"
|
||||
)
|
||||
return
|
||||
if url.port != 443:
|
||||
if not self._webhook_url_invalid:
|
||||
LOGGER.warning(
|
||||
"Webhook not registered - port 443 is required. "
|
||||
"See https://www.home-assistant.io/integrations/withings/#webhook-requirements"
|
||||
)
|
||||
self._webhook_url_invalid = True
|
||||
LOGGER.warning(
|
||||
"Webhook not registered - port 443 is required. "
|
||||
"See https://www.home-assistant.io/integrations/withings/#webhook-requirements"
|
||||
)
|
||||
return
|
||||
|
||||
webhook_name = "Withings"
|
||||
|
||||
@@ -6,7 +6,6 @@ To update, run python3 -m script.hassfest
|
||||
APPLICATION_CREDENTIALS = [
|
||||
"aladdin_connect",
|
||||
"august",
|
||||
"dropbox",
|
||||
"ekeybionyx",
|
||||
"electric_kiwi",
|
||||
"fitbit",
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -158,7 +158,6 @@ FLOWS = {
|
||||
"downloader",
|
||||
"dremel_3d_printer",
|
||||
"drop_connect",
|
||||
"dropbox",
|
||||
"droplet",
|
||||
"dsmr",
|
||||
"dsmr_reader",
|
||||
|
||||
@@ -1473,12 +1473,6 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"dropbox": {
|
||||
"name": "Dropbox",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"droplet": {
|
||||
"name": "Droplet",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -1371,6 +1371,7 @@ class IntentResponse:
|
||||
self.reprompt: dict[str, dict[str, Any]] = {}
|
||||
self.card: dict[str, dict[str, str]] = {}
|
||||
self.error_code: IntentResponseErrorCode | None = None
|
||||
self.intent_targets: list[IntentResponseTarget] = []
|
||||
self.success_results: list[IntentResponseTarget] = []
|
||||
self.failed_results: list[IntentResponseTarget] = []
|
||||
self.matched_states: list[State] = []
|
||||
@@ -1420,6 +1421,14 @@ class IntentResponse:
|
||||
# Speak error message
|
||||
self.async_set_speech(message)
|
||||
|
||||
@callback
|
||||
def async_set_targets(
|
||||
self,
|
||||
intent_targets: list[IntentResponseTarget],
|
||||
) -> None:
|
||||
"""Set response targets."""
|
||||
self.intent_targets = intent_targets
|
||||
|
||||
@callback
|
||||
def async_set_results(
|
||||
self,
|
||||
@@ -1465,6 +1474,11 @@ class IntentResponse:
|
||||
response_data["code"] = self.error_code.value
|
||||
else:
|
||||
# action done or query answer
|
||||
response_data["targets"] = [
|
||||
dataclasses.asdict(target) for target in self.intent_targets
|
||||
]
|
||||
|
||||
# Add success/failed targets
|
||||
response_data["success"] = [
|
||||
dataclasses.asdict(target) for target in self.success_results
|
||||
]
|
||||
|
||||
@@ -165,23 +165,10 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional("supported_features"): [
|
||||
vol.All(cv.ensure_list, [str], _validate_supported_features)
|
||||
],
|
||||
# Unit of measurement of the entity
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.All(cv.ensure_list, [str]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _LegacyEntityFilterSelectorConfig(TypedDict, total=False):
|
||||
"""Class for legacy entity filter support in EntitySelectorConfig.
|
||||
|
||||
Provided for backwards compatibility and remains feature frozen.
|
||||
"""
|
||||
|
||||
integration: str
|
||||
domain: str | list[str]
|
||||
device_class: str | list[str]
|
||||
|
||||
|
||||
# Legacy entity selector config schema used directly under entity selectors
|
||||
# is provided for backwards compatibility and remains feature frozen.
|
||||
# New filtering features should be added under the `filter` key instead.
|
||||
@@ -203,7 +190,6 @@ class EntityFilterSelectorConfig(TypedDict, total=False):
|
||||
domain: str | list[str]
|
||||
device_class: str | list[str]
|
||||
supported_features: list[str]
|
||||
unit_of_measurement: str | list[str]
|
||||
|
||||
|
||||
DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema(
|
||||
@@ -902,15 +888,9 @@ class DurationSelector(Selector[DurationSelectorConfig]):
|
||||
return cast(dict[str, float], data)
|
||||
|
||||
|
||||
class EntitySelectorConfig(
|
||||
BaseSelectorConfig, _LegacyEntityFilterSelectorConfig, total=False
|
||||
):
|
||||
class EntitySelectorConfig(BaseSelectorConfig, EntityFilterSelectorConfig, total=False):
|
||||
"""Class to represent an entity selector config."""
|
||||
|
||||
# Note: The class inherits _LegacyEntityFilterSelectorConfig to keep
|
||||
# support for legacy entity filter at top level for backwards compatibility,
|
||||
# new entity filter options should be added under the `filter` key instead.
|
||||
|
||||
exclude_entities: list[str]
|
||||
include_entities: list[str]
|
||||
multiple: bool
|
||||
@@ -1528,7 +1508,6 @@ class StateSelectorConfig(BaseSelectorConfig, total=False):
|
||||
|
||||
entity_id: str
|
||||
hide_states: list[str]
|
||||
attribute: str
|
||||
multiple: bool
|
||||
|
||||
|
||||
@@ -1551,7 +1530,11 @@ class StateSelector(Selector[StateSelectorConfig]):
|
||||
{
|
||||
vol.Optional("entity_id"): cv.entity_id,
|
||||
vol.Optional("hide_states"): [str],
|
||||
vol.Optional("attribute"): str,
|
||||
# The attribute to filter on, is currently deliberately not
|
||||
# configurable/exposed. We are considering separating state
|
||||
# selectors into two types: one for state and one for attribute.
|
||||
# Limiting the public use, prevents breaking changes in the future.
|
||||
# vol.Optional("attribute"): str,
|
||||
vol.Optional("multiple", default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ SQLAlchemy==2.0.41
|
||||
standard-aifc==3.13.0
|
||||
standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==2.2.0
|
||||
ulid-transform==2.0.2
|
||||
urllib3>=2.0
|
||||
uv==0.10.6
|
||||
voluptuous-openapi==0.2.0
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -1486,16 +1486,6 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.dropbox.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.droplet.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
@@ -72,7 +72,7 @@ dependencies = [
|
||||
"standard-aifc==3.13.0",
|
||||
"standard-telnetlib==3.13.0",
|
||||
"typing-extensions>=4.15.0,<5.0",
|
||||
"ulid-transform==2.2.0",
|
||||
"ulid-transform==2.0.2",
|
||||
"urllib3>=2.0",
|
||||
"uv==0.10.6",
|
||||
"voluptuous==0.15.2",
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -52,7 +52,7 @@ SQLAlchemy==2.0.41
|
||||
standard-aifc==3.13.0
|
||||
standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==2.2.0
|
||||
ulid-transform==2.0.2
|
||||
urllib3>=2.0
|
||||
uv==0.10.6
|
||||
voluptuous-openapi==0.2.0
|
||||
|
||||
5
requirements_all.txt
generated
5
requirements_all.txt
generated
@@ -1672,7 +1672,7 @@ odp-amsterdam==6.1.2
|
||||
oemthermostat==1.1.1
|
||||
|
||||
# homeassistant.components.ohme
|
||||
ohme==1.7.1
|
||||
ohme==1.7.0
|
||||
|
||||
# homeassistant.components.ollama
|
||||
ollama==0.5.1
|
||||
@@ -2562,9 +2562,6 @@ python-clementine-remote==1.0.1
|
||||
# homeassistant.components.digital_ocean
|
||||
python-digitalocean==1.13.2
|
||||
|
||||
# homeassistant.components.dropbox
|
||||
python-dropbox-api==0.1.3
|
||||
|
||||
# homeassistant.components.ecobee
|
||||
python-ecobee-api==0.3.2
|
||||
|
||||
|
||||
5
requirements_test_all.txt
generated
5
requirements_test_all.txt
generated
@@ -1458,7 +1458,7 @@ objgraph==3.5.0
|
||||
odp-amsterdam==6.1.2
|
||||
|
||||
# homeassistant.components.ohme
|
||||
ohme==1.7.1
|
||||
ohme==1.7.0
|
||||
|
||||
# homeassistant.components.ollama
|
||||
ollama==0.5.1
|
||||
@@ -2179,9 +2179,6 @@ python-awair==0.2.5
|
||||
# homeassistant.components.bsblan
|
||||
python-bsblan==5.1.2
|
||||
|
||||
# homeassistant.components.dropbox
|
||||
python-dropbox-api==0.1.3
|
||||
|
||||
# homeassistant.components.ecobee
|
||||
python-ecobee-api==0.3.2
|
||||
|
||||
|
||||
@@ -258,61 +258,3 @@ async def test_zone_set_hvac_mode_api_error(
|
||||
{ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_system_hvac_mode_unmapped(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test system climate entity returns None for unmapped HVAC mode."""
|
||||
status = mock_actron_api.state_manager.get_status.return_value
|
||||
status.user_aircon_settings.is_on = True
|
||||
status.user_aircon_settings.mode = "UNKNOWN_MODE"
|
||||
|
||||
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("climate.test_system")
|
||||
assert state.state == "unknown"
|
||||
|
||||
|
||||
async def test_zone_hvac_mode_unmapped(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_zone: MagicMock,
|
||||
) -> None:
|
||||
"""Test zone climate entity returns None for unmapped HVAC mode."""
|
||||
mock_zone.is_active = True
|
||||
mock_zone.hvac_mode = "UNKNOWN_MODE"
|
||||
|
||||
status = mock_actron_api.state_manager.get_status.return_value
|
||||
status.remote_zone_info = [mock_zone]
|
||||
status.zones = {1: mock_zone}
|
||||
|
||||
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("climate.living_room")
|
||||
assert state.state == "unknown"
|
||||
|
||||
|
||||
async def test_zone_hvac_mode_inactive(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_zone: MagicMock,
|
||||
) -> None:
|
||||
"""Test zone climate entity returns OFF when zone is inactive."""
|
||||
mock_zone.is_active = False
|
||||
|
||||
status = mock_actron_api.state_manager.get_status.return_value
|
||||
status.remote_zone_info = [mock_zone]
|
||||
status.zones = {1: mock_zone}
|
||||
|
||||
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("climate.living_room")
|
||||
assert state.state == "off"
|
||||
|
||||
@@ -252,84 +252,3 @@ async def test_reauth_flow_wrong_account(
|
||||
# Should abort because of wrong account
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "wrong_account"
|
||||
|
||||
|
||||
async def test_user_flow_timeout(
|
||||
hass: HomeAssistant, mock_actron_api: AsyncMock, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test OAuth2 flow when login task raises a non-CannotConnect exception."""
|
||||
|
||||
# Override the default mock to raise a generic exception (not CannotConnect)
|
||||
async def raise_generic_error(device_code):
|
||||
raise RuntimeError("Unexpected error")
|
||||
|
||||
mock_actron_api.poll_for_token = raise_generic_error
|
||||
|
||||
# Start the config flow
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
# Task raises a non-CannotConnect exception, so it goes to timeout
|
||||
assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE
|
||||
assert result["step_id"] == "timeout"
|
||||
|
||||
# Continue to the timeout step
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
# Should show the timeout form
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "timeout"
|
||||
|
||||
# Now fix the mock to allow successful token polling for recovery
|
||||
async def successful_poll_for_token(device_code):
|
||||
await asyncio.sleep(0.1)
|
||||
return {
|
||||
"access_token": "test_access_token",
|
||||
"refresh_token": "test_refresh_token",
|
||||
}
|
||||
|
||||
mock_actron_api.poll_for_token = successful_poll_for_token
|
||||
|
||||
# User clicks retry button
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
# Should start progress again
|
||||
assert result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
assert result["step_id"] == "user"
|
||||
assert result["progress_action"] == "wait_for_authorization"
|
||||
|
||||
# Wait for the progress to complete
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Continue the flow after progress is done
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
# Should create entry on successful recovery
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "test@example.com"
|
||||
|
||||
|
||||
async def test_finish_login_auth_error(
|
||||
hass: HomeAssistant, mock_actron_api: AsyncMock, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test finish_login step when get_user_info raises ActronAirAuthError."""
|
||||
# Start the config flow
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
# Wait for the progress to complete
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Now make get_user_info fail with auth error
|
||||
mock_actron_api.get_user_info = AsyncMock(
|
||||
side_effect=ActronAirAuthError("Auth error getting user info")
|
||||
)
|
||||
|
||||
# Continue the flow after progress is done
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
# Should abort with oauth2_error
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "oauth2_error"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Tests for the Actron Air coordinator."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from actron_neo_api import ActronAirAPIError, ActronAirAuthError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
|
||||
from homeassistant.components.actron_air.coordinator import SCAN_INTERVAL
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
async def test_coordinator_update_auth_error(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test coordinator handles auth error during update."""
|
||||
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
mock_actron_api.update_status.side_effect = ActronAirAuthError("Auth expired")
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# ConfigEntryAuthFailed triggers a reauth flow
|
||||
assert len(hass.config_entries.flow.async_progress()) == 1
|
||||
|
||||
|
||||
async def test_coordinator_update_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test coordinator handles API error during update."""
|
||||
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_actron_api.update_status.side_effect = ActronAirAPIError("API error")
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# UpdateFailed sets last_update_success to False on the coordinator
|
||||
coordinator = list(mock_config_entry.runtime_data.system_coordinators.values())[0]
|
||||
assert coordinator.last_update_success is False
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Tests for the Actron Air integration setup."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from actron_neo_api import ActronAirAPIError, ActronAirAuthError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_setup_entry_auth_error(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup entry raises ConfigEntryAuthFailed on auth error."""
|
||||
mock_actron_api.get_ac_systems.side_effect = ActronAirAuthError("Auth failed")
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_setup_entry_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup entry raises ConfigEntryNotReady on API error."""
|
||||
mock_actron_api.get_ac_systems.side_effect = ActronAirAPIError("API failed")
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
@@ -1245,6 +1245,8 @@
|
||||
failed_results=list([
|
||||
]),
|
||||
intent=None,
|
||||
intent_targets=list([
|
||||
]),
|
||||
language='en',
|
||||
matched_states=list([
|
||||
]),
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -345,6 +347,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -575,6 +579,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -652,6 +658,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -704,6 +712,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -756,6 +766,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
|
||||
@@ -661,6 +661,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
|
||||
@@ -65,6 +65,7 @@ async def test_broadcast_intent(
|
||||
"type": intent.IntentResponseTargetType.ENTITY,
|
||||
},
|
||||
],
|
||||
"targets": [],
|
||||
},
|
||||
"language": "en",
|
||||
"response_type": "action_done",
|
||||
@@ -97,6 +98,7 @@ async def test_broadcast_intent(
|
||||
"type": intent.IntentResponseTargetType.ENTITY,
|
||||
},
|
||||
],
|
||||
"targets": [],
|
||||
},
|
||||
"language": "en",
|
||||
"response_type": "action_done",
|
||||
@@ -128,6 +130,7 @@ async def test_broadcast_intent_excluded_domains(
|
||||
"data": {
|
||||
"failed": [],
|
||||
"success": [], # no satellites
|
||||
"targets": [],
|
||||
},
|
||||
"language": "en",
|
||||
"response_type": "action_done",
|
||||
|
||||
@@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF
|
||||
@@ -237,8 +236,7 @@ async def test_authentication_error(
|
||||
assert port_field.default() == 8080
|
||||
assert passkey_field.default() == "secret"
|
||||
assert username_field.default() == "testuser"
|
||||
# Password should never be pre-filled for security reasons
|
||||
assert password_field.default is vol.UNDEFINED
|
||||
assert password_field.default() == "wrongpassword"
|
||||
|
||||
|
||||
async def test_authentication_error_vs_connection_error(
|
||||
@@ -1061,128 +1059,3 @@ async def test_zeroconf_discovery_auth_error_during_confirm(
|
||||
|
||||
# Should show the discovery_confirm form again with auth error
|
||||
_assert_form_result(result, "discovery_confirm", {"base": "invalid_auth"})
|
||||
|
||||
|
||||
async def test_reconfigure_flow_success(
|
||||
hass: HomeAssistant,
|
||||
mock_bsblan: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test successful reconfiguration flow."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
_assert_form_result(result, "reconfigure")
|
||||
|
||||
result = await _configure_flow(
|
||||
hass,
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOST: "192.168.1.50",
|
||||
CONF_PORT: 8080,
|
||||
CONF_PASSKEY: "new_passkey",
|
||||
CONF_USERNAME: "new_admin",
|
||||
CONF_PASSWORD: "new_password",
|
||||
},
|
||||
)
|
||||
|
||||
_assert_abort_result(result, "reconfigure_successful")
|
||||
|
||||
assert mock_config_entry.data[CONF_HOST] == "192.168.1.50"
|
||||
assert mock_config_entry.data[CONF_PORT] == 8080
|
||||
assert mock_config_entry.data[CONF_PASSKEY] == "new_passkey"
|
||||
assert mock_config_entry.data[CONF_USERNAME] == "new_admin"
|
||||
assert mock_config_entry.data[CONF_PASSWORD] == "new_password"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "error"),
|
||||
[
|
||||
(BSBLANAuthError, "invalid_auth"),
|
||||
(BSBLANConnectionError, "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_reconfigure_flow_error_recovery(
|
||||
hass: HomeAssistant,
|
||||
mock_bsblan: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
side_effect: Exception,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test reconfigure flow can recover from errors."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
mock_bsblan.device.side_effect = side_effect
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
_assert_form_result(result, "reconfigure")
|
||||
|
||||
result = await _configure_flow(
|
||||
hass,
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOST: "192.168.1.50",
|
||||
CONF_PORT: 80,
|
||||
CONF_PASSKEY: "wrong_key",
|
||||
CONF_USERNAME: "admin",
|
||||
CONF_PASSWORD: "wrong_password",
|
||||
},
|
||||
)
|
||||
|
||||
_assert_form_result(result, "reconfigure", {"base": error})
|
||||
|
||||
# Recover: clear the error and submit correct credentials
|
||||
mock_bsblan.device.side_effect = None
|
||||
|
||||
result = await _configure_flow(
|
||||
hass,
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOST: "192.168.1.50",
|
||||
CONF_PORT: 8080,
|
||||
CONF_PASSKEY: "new_passkey",
|
||||
CONF_USERNAME: "new_admin",
|
||||
CONF_PASSWORD: "new_password",
|
||||
},
|
||||
)
|
||||
|
||||
_assert_abort_result(result, "reconfigure_successful")
|
||||
|
||||
assert mock_config_entry.data[CONF_HOST] == "192.168.1.50"
|
||||
assert mock_config_entry.data[CONF_PORT] == 8080
|
||||
assert mock_config_entry.data[CONF_PASSKEY] == "new_passkey"
|
||||
assert mock_config_entry.data[CONF_USERNAME] == "new_admin"
|
||||
assert mock_config_entry.data[CONF_PASSWORD] == "new_password"
|
||||
|
||||
|
||||
async def test_reconfigure_flow_unique_id_mismatch(
|
||||
hass: HomeAssistant,
|
||||
mock_bsblan: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reconfigure flow aborts when connecting to a different device."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Mock device returning a different MAC address
|
||||
device = mock_bsblan.device.return_value
|
||||
device.MAC = "aa:bb:cc:dd:ee:ff"
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
_assert_form_result(result, "reconfigure")
|
||||
|
||||
result = await _configure_flow(
|
||||
hass,
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOST: "192.168.1.99",
|
||||
CONF_PORT: 80,
|
||||
CONF_PASSKEY: "1234",
|
||||
CONF_USERNAME: "admin",
|
||||
CONF_PASSWORD: "admin1234",
|
||||
},
|
||||
)
|
||||
|
||||
_assert_abort_result(result, "unique_id_mismatch")
|
||||
|
||||
@@ -250,7 +250,7 @@ async def test_prepare_chat_for_generation_passes_messages_through(
|
||||
"speech": {"plain": {"speech": "12:00 PM", "extra_data": None}},
|
||||
"response_type": "action_done",
|
||||
"speech_slots": {"time": datetime.time(12, 0)},
|
||||
"data": {"success": [], "failed": []},
|
||||
"data": {"targets": [], "success": [], "failed": []},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en-us',
|
||||
'response_type': 'action_done',
|
||||
@@ -35,6 +37,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en-us',
|
||||
'response_type': 'action_done',
|
||||
@@ -59,6 +63,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -88,6 +94,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -117,6 +125,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -188,6 +198,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -217,6 +229,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -246,6 +260,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -275,6 +291,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -325,6 +343,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -396,6 +416,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -446,6 +468,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -475,6 +499,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
|
||||
@@ -283,6 +283,8 @@
|
||||
'type': 'entity',
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -312,6 +314,8 @@
|
||||
'type': 'entity',
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'test-language',
|
||||
'response_type': 'action_done',
|
||||
@@ -61,6 +63,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -90,6 +94,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -119,6 +125,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -148,6 +156,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -177,6 +187,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -206,6 +218,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -235,6 +249,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
@@ -264,6 +280,8 @@
|
||||
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
|
||||
}),
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'language': 'en',
|
||||
'response_type': 'action_done',
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for the Dropbox integration."""
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Shared fixtures for Dropbox integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.dropbox.const import DOMAIN, OAUTH2_SCOPES
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
ACCOUNT_ID = "dbid:1234567890abcdef"
|
||||
ACCOUNT_EMAIL = "user@example.com"
|
||||
CONFIG_ENTRY_TITLE = "Dropbox test account"
|
||||
TEST_AGENT_ID = f"{DOMAIN}.{ACCOUNT_ID}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_credentials(hass: HomeAssistant) -> None:
|
||||
"""Set up application credentials for Dropbox."""
|
||||
|
||||
assert await async_setup_component(hass, "application_credentials", {})
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential(CLIENT_ID, CLIENT_SECRET),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account_info() -> SimpleNamespace:
|
||||
"""Return mocked Dropbox account information."""
|
||||
|
||||
return SimpleNamespace(account_id=ACCOUNT_ID, email=ACCOUNT_EMAIL)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return a default Dropbox config entry."""
|
||||
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=ACCOUNT_ID,
|
||||
title=CONFIG_ENTRY_TITLE,
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
"token": {
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_at": 9_999_999_999,
|
||||
"scope": " ".join(OAUTH2_SCOPES),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.dropbox.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dropbox_client(account_info: SimpleNamespace) -> Generator[MagicMock]:
|
||||
"""Patch DropboxAPIClient to exercise auth while mocking API calls."""
|
||||
|
||||
client = MagicMock()
|
||||
client.list_folder = AsyncMock(return_value=[])
|
||||
client.download_file = MagicMock()
|
||||
client.upload_file = AsyncMock()
|
||||
client.delete_file = AsyncMock()
|
||||
|
||||
captured_auth = None
|
||||
|
||||
def capture_auth(auth):
|
||||
nonlocal captured_auth
|
||||
captured_auth = auth
|
||||
return client
|
||||
|
||||
async def get_account_info_with_auth():
|
||||
await captured_auth.async_get_access_token()
|
||||
return client.get_account_info.return_value
|
||||
|
||||
client.get_account_info = AsyncMock(
|
||||
side_effect=get_account_info_with_auth,
|
||||
return_value=account_info,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.dropbox.config_flow.DropboxAPIClient",
|
||||
side_effect=capture_auth,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.dropbox.DropboxAPIClient",
|
||||
side_effect=capture_auth,
|
||||
),
|
||||
):
|
||||
yield client
|
||||
@@ -1,577 +0,0 @@
|
||||
"""Test the Dropbox backup platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from io import StringIO
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from python_dropbox_api import DropboxAuthException
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
DOMAIN as BACKUP_DOMAIN,
|
||||
AddonInfo,
|
||||
AgentBackup,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.components.dropbox.backup import (
|
||||
DropboxFileOrFolderNotFoundException,
|
||||
DropboxUnknownException,
|
||||
async_register_backup_agents_listener,
|
||||
)
|
||||
from homeassistant.components.dropbox.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_REAUTH
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import mock_stream
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
TEST_AGENT_BACKUP = AgentBackup(
|
||||
addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
|
||||
backup_id="dropbox-backup",
|
||||
database_included=True,
|
||||
date="2025-01-01T00:00:00.000Z",
|
||||
extra_metadata={"with_automatic_settings": False},
|
||||
folders=[],
|
||||
homeassistant_included=True,
|
||||
homeassistant_version="2024.12.0",
|
||||
name="Dropbox backup",
|
||||
protected=False,
|
||||
size=2048,
|
||||
)
|
||||
|
||||
TEST_AGENT_BACKUP_RESULT = {
|
||||
"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}],
|
||||
"agents": {TEST_AGENT_ID: {"protected": False, "size": 2048}},
|
||||
"backup_id": TEST_AGENT_BACKUP.backup_id,
|
||||
"database_included": True,
|
||||
"date": TEST_AGENT_BACKUP.date,
|
||||
"extra_metadata": {"with_automatic_settings": False},
|
||||
"failed_addons": [],
|
||||
"failed_agent_ids": [],
|
||||
"failed_folders": [],
|
||||
"folders": [],
|
||||
"homeassistant_included": True,
|
||||
"homeassistant_version": TEST_AGENT_BACKUP.homeassistant_version,
|
||||
"name": TEST_AGENT_BACKUP.name,
|
||||
"with_automatic_settings": None,
|
||||
}
|
||||
|
||||
|
||||
def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
"""Return the suggested filenames for the backup and metadata."""
|
||||
base_name = suggested_filename(backup).rsplit(".", 1)[0]
|
||||
return f"{base_name}.tar", f"{base_name}.metadata.json"
|
||||
|
||||
|
||||
async def _mock_metadata_stream(backup: AgentBackup) -> AsyncIterator[bytes]:
|
||||
"""Create a mock metadata download stream."""
|
||||
yield json.dumps(backup.as_dict()).encode()
|
||||
|
||||
|
||||
def _setup_list_folder_with_backup(
|
||||
mock_dropbox_client: Mock,
|
||||
backup: AgentBackup,
|
||||
) -> None:
|
||||
"""Set up mock to return a backup in list_folder and download_file."""
|
||||
tar_name, metadata_name = _suggested_filenames(backup)
|
||||
mock_dropbox_client.list_folder = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(name=tar_name),
|
||||
SimpleNamespace(name=metadata_name),
|
||||
]
|
||||
)
|
||||
mock_dropbox_client.download_file = Mock(return_value=_mock_metadata_stream(backup))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_dropbox_client,
|
||||
) -> None:
|
||||
"""Set up the Dropbox and Backup integrations for testing."""
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
mock_dropbox_client.reset_mock()
|
||||
|
||||
|
||||
async def test_agents_info(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test listing available backup agents."""
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({"type": "backup/agents/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == {
|
||||
"agents": [
|
||||
{"agent_id": "backup.local", "name": "local"},
|
||||
{"agent_id": TEST_AGENT_ID, "name": CONFIG_ENTRY_TITLE},
|
||||
]
|
||||
}
|
||||
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await client.send_json_auto_id({"type": "backup/agents/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == {
|
||||
"agents": [{"agent_id": "backup.local", "name": "local"}]
|
||||
}
|
||||
|
||||
|
||||
async def test_agents_list_backups(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test listing backups via the Dropbox agent."""
|
||||
|
||||
_setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "backup/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"]["agent_errors"] == {}
|
||||
assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT]
|
||||
mock_dropbox_client.list_folder.assert_awaited()
|
||||
|
||||
|
||||
async def test_agents_list_backups_metadata_without_tar(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that orphaned metadata files are skipped with a warning."""
|
||||
|
||||
mock_dropbox_client.list_folder = AsyncMock(
|
||||
return_value=[SimpleNamespace(name="orphan.metadata.json")]
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "backup/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"]["agent_errors"] == {}
|
||||
assert response["result"]["backups"] == []
|
||||
assert "without matching backup file" in caplog.text
|
||||
|
||||
|
||||
async def test_agents_list_backups_invalid_metadata(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that invalid metadata files are skipped with a warning."""
|
||||
|
||||
async def _invalid_stream() -> AsyncIterator[bytes]:
|
||||
yield b"not valid json"
|
||||
|
||||
mock_dropbox_client.list_folder = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(name="backup.tar"),
|
||||
SimpleNamespace(name="backup.metadata.json"),
|
||||
]
|
||||
)
|
||||
mock_dropbox_client.download_file = Mock(return_value=_invalid_stream())
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "backup/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"]["agent_errors"] == {}
|
||||
assert response["result"]["backups"] == []
|
||||
assert "Skipping invalid metadata file" in caplog.text
|
||||
|
||||
|
||||
async def test_agents_list_backups_fail(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test handling list backups failures."""
|
||||
|
||||
mock_dropbox_client.list_folder = AsyncMock(
|
||||
side_effect=DropboxUnknownException("boom")
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "backup/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"]["backups"] == []
|
||||
assert response["result"]["agent_errors"] == {
|
||||
TEST_AGENT_ID: "Failed to list backups"
|
||||
}
|
||||
|
||||
|
||||
async def test_agents_list_backups_reauth(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reauthentication is triggered on auth error."""
|
||||
|
||||
mock_dropbox_client.list_folder = AsyncMock(
|
||||
side_effect=DropboxAuthException("auth failed")
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "backup/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"]["backups"] == []
|
||||
assert response["result"]["agent_errors"] == {TEST_AGENT_ID: "Authentication error"}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert len(flows) == 1
|
||||
|
||||
flow = flows[0]
|
||||
assert flow["step_id"] == "reauth_confirm"
|
||||
assert flow["handler"] == DOMAIN
|
||||
assert flow["context"]["source"] == SOURCE_REAUTH
|
||||
assert flow["context"]["entry_id"] == mock_config_entry.entry_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"backup_id",
|
||||
[TEST_AGENT_BACKUP.backup_id, "other-backup"],
|
||||
ids=["found", "not_found"],
|
||||
)
|
||||
async def test_agents_get_backup(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
backup_id: str,
|
||||
) -> None:
|
||||
"""Test retrieving a backup's metadata."""
|
||||
|
||||
_setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"]["agent_errors"] == {}
|
||||
if backup_id == TEST_AGENT_BACKUP.backup_id:
|
||||
assert response["result"]["backup"] == TEST_AGENT_BACKUP_RESULT
|
||||
else:
|
||||
assert response["result"]["backup"] is None
|
||||
|
||||
|
||||
async def test_agents_download(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test downloading a backup file."""
|
||||
|
||||
tar_name, metadata_name = _suggested_filenames(TEST_AGENT_BACKUP)
|
||||
|
||||
mock_dropbox_client.list_folder = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(name=tar_name),
|
||||
SimpleNamespace(name=metadata_name),
|
||||
]
|
||||
)
|
||||
|
||||
def download_side_effect(path: str) -> AsyncIterator[bytes]:
|
||||
if path == f"/{tar_name}":
|
||||
return mock_stream(b"backup data")
|
||||
return _mock_metadata_stream(TEST_AGENT_BACKUP)
|
||||
|
||||
mock_dropbox_client.download_file = Mock(side_effect=download_side_effect)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get(
|
||||
f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}"
|
||||
)
|
||||
|
||||
assert resp.status == 200
|
||||
assert await resp.content.read() == b"backup data"
|
||||
|
||||
|
||||
async def test_agents_download_fail(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test handling download failures."""
|
||||
|
||||
mock_dropbox_client.list_folder = AsyncMock(
|
||||
side_effect=DropboxUnknownException("boom")
|
||||
)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get(
|
||||
f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}"
|
||||
)
|
||||
|
||||
assert resp.status == 500
|
||||
body = await resp.content.read()
|
||||
assert b"Failed to get backup" in body
|
||||
|
||||
|
||||
async def test_agents_download_not_found(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test download when backup disappears between get and download."""
|
||||
|
||||
tar_name, metadata_name = _suggested_filenames(TEST_AGENT_BACKUP)
|
||||
files = [
|
||||
SimpleNamespace(name=tar_name),
|
||||
SimpleNamespace(name=metadata_name),
|
||||
]
|
||||
|
||||
# First list_folder call (async_get_backup) returns the backup;
|
||||
# second call (async_download_backup) returns empty, simulating deletion.
|
||||
mock_dropbox_client.list_folder = AsyncMock(side_effect=[files, []])
|
||||
mock_dropbox_client.download_file = Mock(
|
||||
return_value=_mock_metadata_stream(TEST_AGENT_BACKUP)
|
||||
)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get(
|
||||
f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}"
|
||||
)
|
||||
|
||||
assert resp.status == 404
|
||||
assert await resp.content.read() == b""
|
||||
|
||||
|
||||
async def test_agents_download_file_not_found(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test download when Dropbox file is not found returns 404."""
|
||||
|
||||
mock_dropbox_client.list_folder = AsyncMock(
|
||||
side_effect=DropboxFileOrFolderNotFoundException("not found")
|
||||
)
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get(
|
||||
f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}"
|
||||
)
|
||||
|
||||
assert resp.status == 404
|
||||
|
||||
|
||||
async def test_agents_download_metadata_not_found(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test download when metadata lookup fails."""
|
||||
|
||||
mock_dropbox_client.list_folder = AsyncMock(return_value=[])
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get(
|
||||
f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}"
|
||||
)
|
||||
|
||||
assert resp.status == 404
|
||||
assert await resp.content.read() == b""
|
||||
|
||||
|
||||
async def test_agents_upload(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test uploading a backup to Dropbox."""
|
||||
|
||||
mock_dropbox_client.upload_file = AsyncMock(return_value=None)
|
||||
|
||||
client = await hass_client()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
|
||||
return_value=TEST_AGENT_BACKUP,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.read_backup",
|
||||
return_value=TEST_AGENT_BACKUP,
|
||||
),
|
||||
patch("pathlib.Path.open") as mocked_open,
|
||||
):
|
||||
mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
|
||||
resp = await client.post(
|
||||
f"/api/backup/upload?agent_id={TEST_AGENT_ID}",
|
||||
data={"file": StringIO("test")},
|
||||
)
|
||||
|
||||
assert resp.status == 201
|
||||
assert f"Uploading backup {TEST_AGENT_BACKUP.backup_id} to agents" in caplog.text
|
||||
assert mock_dropbox_client.upload_file.await_count == 2
|
||||
|
||||
|
||||
async def test_agents_upload_fail(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test that backup tar is cleaned up when metadata upload fails."""
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def upload_side_effect(path: str, stream: AsyncIterator[bytes]) -> None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
async for _ in stream:
|
||||
pass
|
||||
if call_count == 2:
|
||||
raise DropboxUnknownException("metadata upload failed")
|
||||
|
||||
mock_dropbox_client.upload_file = AsyncMock(side_effect=upload_side_effect)
|
||||
mock_dropbox_client.delete_file = AsyncMock()
|
||||
|
||||
client = await hass_client()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
|
||||
return_value=TEST_AGENT_BACKUP,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.read_backup",
|
||||
return_value=TEST_AGENT_BACKUP,
|
||||
),
|
||||
patch("pathlib.Path.open") as mocked_open,
|
||||
):
|
||||
mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
|
||||
resp = await client.post(
|
||||
f"/api/backup/upload?agent_id={TEST_AGENT_ID}",
|
||||
data={"file": StringIO("test")},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert resp.status == 201
|
||||
assert "Failed to upload backup" in caplog.text
|
||||
mock_dropbox_client.delete_file.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_agents_delete(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test deleting a backup."""
|
||||
|
||||
_setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP)
|
||||
mock_dropbox_client.delete_file = AsyncMock(return_value=None)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "backup/delete",
|
||||
"backup_id": TEST_AGENT_BACKUP.backup_id,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == {"agent_errors": {}}
|
||||
assert mock_dropbox_client.delete_file.await_count == 2
|
||||
|
||||
|
||||
async def test_agents_delete_fail(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test error handling when delete fails."""
|
||||
|
||||
mock_dropbox_client.list_folder = AsyncMock(
|
||||
side_effect=DropboxUnknownException("boom")
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "backup/delete",
|
||||
"backup_id": TEST_AGENT_BACKUP.backup_id,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == {
|
||||
"agent_errors": {TEST_AGENT_ID: "Failed to delete backup"}
|
||||
}
|
||||
|
||||
|
||||
async def test_agents_delete_not_found(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_dropbox_client: Mock,
|
||||
) -> None:
|
||||
"""Test deleting a backup that does not exist."""
|
||||
|
||||
mock_dropbox_client.list_folder = AsyncMock(return_value=[])
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "backup/delete",
|
||||
"backup_id": TEST_AGENT_BACKUP.backup_id,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == {"agent_errors": {}}
|
||||
|
||||
|
||||
async def test_remove_backup_agents_listener(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test removing a backup agent listener."""
|
||||
listener = Mock()
|
||||
remove = async_register_backup_agents_listener(hass, listener=listener)
|
||||
|
||||
assert DATA_BACKUP_AGENT_LISTENERS in hass.data
|
||||
assert listener in hass.data[DATA_BACKUP_AGENT_LISTENERS]
|
||||
|
||||
# Remove all other listeners to test the cleanup path
|
||||
hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener]
|
||||
|
||||
remove()
|
||||
|
||||
assert DATA_BACKUP_AGENT_LISTENERS not in hass.data
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Test the Dropbox config flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.dropbox.const import (
|
||||
DOMAIN,
|
||||
OAUTH2_AUTHORIZE,
|
||||
OAUTH2_SCOPES,
|
||||
OAUTH2_TOKEN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .conftest import ACCOUNT_EMAIL, ACCOUNT_ID, CLIENT_ID
|
||||
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_full_flow(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_dropbox_client,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test creating a new config entry through the OAuth flow."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
|
||||
result_url = URL(result["url"])
|
||||
assert f"{result_url.origin()}{result_url.path}" == OAUTH2_AUTHORIZE
|
||||
assert result_url.query["response_type"] == "code"
|
||||
assert result_url.query["client_id"] == CLIENT_ID
|
||||
assert (
|
||||
result_url.query["redirect_uri"] == "https://example.com/auth/external/callback"
|
||||
)
|
||||
assert result_url.query["state"] == state
|
||||
assert result_url.query["scope"] == " ".join(OAUTH2_SCOPES)
|
||||
assert result_url.query["token_access_type"] == "offline"
|
||||
assert result_url.query["code_challenge"]
|
||||
assert result_url.query["code_challenge_method"] == "S256"
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == ACCOUNT_EMAIL
|
||||
assert result["data"]["token"]["access_token"] == "mock-access-token"
|
||||
assert result["result"].unique_id == ACCOUNT_ID
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_already_configured(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_config_entry,
|
||||
mock_dropbox_client,
|
||||
) -> None:
|
||||
"""Test aborting when the account is already configured."""
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"new_account_info",
|
||||
"expected_reason",
|
||||
"expected_setup_calls",
|
||||
"expected_access_token",
|
||||
),
|
||||
[
|
||||
(
|
||||
SimpleNamespace(account_id=ACCOUNT_ID, email=ACCOUNT_EMAIL),
|
||||
"reauth_successful",
|
||||
1,
|
||||
"updated-access-token",
|
||||
),
|
||||
(
|
||||
SimpleNamespace(account_id="dbid:different", email="other@example.com"),
|
||||
"wrong_account",
|
||||
0,
|
||||
"mock-access-token",
|
||||
),
|
||||
],
|
||||
ids=["success", "wrong_account"],
|
||||
)
|
||||
async def test_reauth_flow(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_config_entry,
|
||||
mock_dropbox_client,
|
||||
mock_setup_entry: AsyncMock,
|
||||
new_account_info: SimpleNamespace,
|
||||
expected_reason: str,
|
||||
expected_setup_calls: int,
|
||||
expected_access_token: str,
|
||||
) -> None:
|
||||
"""Test reauthentication flow outcomes."""
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
mock_dropbox_client.get_account_info.return_value = new_account_info
|
||||
|
||||
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"], {})
|
||||
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "updated-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 120,
|
||||
},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == expected_reason
|
||||
assert mock_setup_entry.await_count == expected_setup_calls
|
||||
|
||||
assert mock_config_entry.data["token"]["access_token"] == expected_access_token
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Test the Dropbox integration setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from python_dropbox_api import DropboxAuthException, DropboxUnknownException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_dropbox_client")
|
||||
async def test_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test successful setup of a config entry."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
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
|
||||
|
||||
|
||||
async def test_setup_entry_auth_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_dropbox_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test setup failure when authentication fails."""
|
||||
mock_dropbox_client.get_account_info.side_effect = DropboxAuthException(
|
||||
"Invalid token"
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect",
|
||||
[DropboxUnknownException("Unknown error"), TimeoutError("Connection timed out")],
|
||||
ids=["unknown_exception", "timeout_error"],
|
||||
)
|
||||
async def test_setup_entry_not_ready(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_dropbox_client: AsyncMock,
|
||||
side_effect: Exception,
|
||||
) -> None:
|
||||
"""Test setup retry when the service is temporarily unavailable."""
|
||||
mock_dropbox_client.get_account_info.side_effect = side_effect
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
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_setup_entry_implementation_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup retry when OAuth implementation is unavailable."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.dropbox.async_get_config_entry_implementation",
|
||||
side_effect=ImplementationUnavailableError,
|
||||
):
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_dropbox_client")
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test unloading a config entry."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
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
|
||||
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
@@ -19,7 +19,6 @@ async def test_buttons(
|
||||
init_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test standard Fully Kiosk buttons."""
|
||||
mock_fully_kiosk.reset_mock()
|
||||
entry = entity_registry.async_get("button.amazon_fire_restart_browser")
|
||||
assert entry
|
||||
assert entry.unique_id == "abcdef-123456-restartApp"
|
||||
@@ -30,10 +29,7 @@ async def test_buttons(
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_fully_kiosk.restartApp.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getDeviceInfo.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getSettings.mock_calls) == 1
|
||||
|
||||
mock_fully_kiosk.reset_mock()
|
||||
entry = entity_registry.async_get("button.amazon_fire_restart_device")
|
||||
assert entry
|
||||
assert entry.unique_id == "abcdef-123456-rebootDevice"
|
||||
@@ -44,10 +40,7 @@ async def test_buttons(
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_fully_kiosk.rebootDevice.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getDeviceInfo.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getSettings.mock_calls) == 1
|
||||
|
||||
mock_fully_kiosk.reset_mock()
|
||||
entry = entity_registry.async_get("button.amazon_fire_bring_to_foreground")
|
||||
assert entry
|
||||
assert entry.unique_id == "abcdef-123456-toForeground"
|
||||
@@ -58,10 +51,7 @@ async def test_buttons(
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_fully_kiosk.toForeground.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getDeviceInfo.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getSettings.mock_calls) == 1
|
||||
|
||||
mock_fully_kiosk.reset_mock()
|
||||
entry = entity_registry.async_get("button.amazon_fire_send_to_background")
|
||||
assert entry
|
||||
assert entry.unique_id == "abcdef-123456-toBackground"
|
||||
@@ -72,10 +62,7 @@ async def test_buttons(
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_fully_kiosk.toBackground.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getDeviceInfo.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getSettings.mock_calls) == 1
|
||||
|
||||
mock_fully_kiosk.reset_mock()
|
||||
entry = entity_registry.async_get("button.amazon_fire_load_start_url")
|
||||
assert entry
|
||||
assert entry.unique_id == "abcdef-123456-loadStartUrl"
|
||||
@@ -86,10 +73,7 @@ async def test_buttons(
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_fully_kiosk.loadStartUrl.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getDeviceInfo.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getSettings.mock_calls) == 1
|
||||
|
||||
mock_fully_kiosk.reset_mock()
|
||||
entry = entity_registry.async_get("button.amazon_fire_clear_browser_cache")
|
||||
assert entry
|
||||
assert entry.unique_id == "abcdef-123456-clearCache"
|
||||
@@ -100,24 +84,6 @@ async def test_buttons(
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_fully_kiosk.clearCache.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getDeviceInfo.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getSettings.mock_calls) == 1
|
||||
|
||||
mock_fully_kiosk.reset_mock()
|
||||
entry = entity_registry.async_get(
|
||||
"button.amazon_fire_trigger_motion_activity",
|
||||
)
|
||||
assert entry
|
||||
assert entry.unique_id == "abcdef-123456-triggerMotion"
|
||||
await hass.services.async_call(
|
||||
button.DOMAIN,
|
||||
button.SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: "button.amazon_fire_trigger_motion_activity"},
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_fully_kiosk.triggerMotion.mock_calls) == 1
|
||||
assert len(mock_fully_kiosk.getDeviceInfo.mock_calls) == 0
|
||||
assert len(mock_fully_kiosk.getSettings.mock_calls) == 0
|
||||
|
||||
assert entry.device_id
|
||||
device_entry = device_registry.async_get(entry.device_id)
|
||||
|
||||
@@ -310,94 +310,6 @@ async def test_reauth(
|
||||
assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"new_email",
|
||||
"expected_abort_reason",
|
||||
"expected_placeholders",
|
||||
"expected_access_token",
|
||||
"expected_setup_calls",
|
||||
),
|
||||
[
|
||||
(TEST_USER_EMAIL, "reconfigure_successful", None, "updated-access-token", 1),
|
||||
(
|
||||
"other.user@domain.com",
|
||||
"wrong_account",
|
||||
{"email": TEST_USER_EMAIL},
|
||||
"mock-access-token",
|
||||
0,
|
||||
),
|
||||
],
|
||||
ids=["reconfigure_successful", "wrong_account"],
|
||||
)
|
||||
async def test_reconfigure(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
config_entry: MockConfigEntry,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_api: MagicMock,
|
||||
new_email: str,
|
||||
expected_abort_reason: str,
|
||||
expected_placeholders: dict[str, str] | None,
|
||||
expected_access_token: str,
|
||||
expected_setup_calls: int,
|
||||
) -> None:
|
||||
"""Test the reconfiguration flow."""
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
assert result["url"] == (
|
||||
f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
|
||||
"&redirect_uri=https://example.com/auth/external/callback"
|
||||
f"&state={state}&scope=https://www.googleapis.com/auth/drive.file"
|
||||
"&access_type=offline&prompt=consent"
|
||||
)
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
# Prepare API responses
|
||||
mock_api.get_user = AsyncMock(return_value={"user": {"emailAddress": new_email}})
|
||||
aioclient_mock.post(
|
||||
GOOGLE_TOKEN_URI,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "updated-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.google_drive.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert len(mock_setup.mock_calls) == expected_setup_calls
|
||||
|
||||
assert result.get("type") is FlowResultType.ABORT
|
||||
assert result.get("reason") == expected_abort_reason
|
||||
assert result.get("description_placeholders") == expected_placeholders
|
||||
|
||||
assert config_entry.unique_id == TEST_USER_EMAIL
|
||||
assert "token" in config_entry.data
|
||||
|
||||
# Verify access token is refreshed
|
||||
assert config_entry.data["token"].get("access_token") == expected_access_token
|
||||
assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_already_configured(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
|
||||
import http
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from google_drive_api.exceptions import GoogleDriveApiError
|
||||
import pytest
|
||||
@@ -12,9 +12,6 @@ import pytest
|
||||
from homeassistant.components.google_drive.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
@@ -157,20 +154,3 @@ async def test_expired_token_refresh_failure(
|
||||
# Verify a transient failure has occurred
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert entries[0].state is expected_state
|
||||
|
||||
|
||||
async def test_oauth_implementation_not_available(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.google_drive.async_get_config_entry_implementation",
|
||||
side_effect=ImplementationUnavailableError,
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
response={
|
||||
'data': {
|
||||
'failed': [],
|
||||
'success': []
|
||||
'success': [],
|
||||
'targets': []
|
||||
},
|
||||
'response_type': 'action_done',
|
||||
'speech': {
|
||||
|
||||
@@ -117,7 +117,7 @@ async def test_function_call(
|
||||
"speech": {"plain": {"speech": "4:24 PM", "extra_data": None}},
|
||||
"response_type": "action_done",
|
||||
"speech_slots": {"time": datetime.time(16, 24, 17, 813343)},
|
||||
"data": {"success": [], "failed": []},
|
||||
"data": {"targets": [], "success": [], "failed": []},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -42,19 +42,16 @@ async def test_config_not_ready(
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_google_weather_api: AsyncMock,
|
||||
failing_api_method: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test for setup failure if an API call fails."""
|
||||
getattr(
|
||||
mock_google_weather_api, failing_api_method
|
||||
).side_effect = GoogleWeatherApiError("API error")
|
||||
).side_effect = GoogleWeatherApiError()
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
assert "Error fetching weather data: API error" in caplog.text
|
||||
|
||||
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -949,61 +949,6 @@ async def test_supervisor_issues_detached_addon_missing(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("all_setup_requests")
|
||||
async def test_supervisor_issues_ntp_sync_failed(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test supervisor issue for NTP sync failed."""
|
||||
mock_resolution_info(supervisor_client)
|
||||
|
||||
result = await async_setup_component(hass, "hassio", {})
|
||||
assert result
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "issue_changed",
|
||||
"data": {
|
||||
"uuid": (issue_uuid := uuid4().hex),
|
||||
"type": "ntp_sync_failed",
|
||||
"context": "system",
|
||||
"reference": None,
|
||||
"suggestions": [
|
||||
{
|
||||
"uuid": uuid4().hex,
|
||||
"type": "enable_ntp",
|
||||
"context": "system",
|
||||
"reference": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await client.send_json({"id": 2, "type": "repairs/list_issues"})
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert len(msg["result"]["issues"]) == 1
|
||||
assert_issue_repair_in_list(
|
||||
msg["result"]["issues"],
|
||||
uuid=issue_uuid,
|
||||
context="system",
|
||||
type_="ntp_sync_failed",
|
||||
fixable=True,
|
||||
placeholders=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("all_setup_requests")
|
||||
async def test_supervisor_issues_disk_lifetime(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -402,152 +402,6 @@ async def test_supervisor_issue_repair_flow_skip_confirmation(
|
||||
supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("all_setup_requests")
|
||||
async def test_supervisor_issue_ntp_sync_failed_repair_flow(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
hass_client: ClientSessionGenerator,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test fix flow for NTP sync failed supervisor issue."""
|
||||
mock_resolution_info(
|
||||
supervisor_client,
|
||||
issues=[
|
||||
Issue(
|
||||
type=IssueType.NTP_SYNC_FAILED,
|
||||
context=ContextType.SYSTEM,
|
||||
reference=None,
|
||||
uuid=(issue_uuid := uuid4()),
|
||||
),
|
||||
],
|
||||
suggestions_by_issue={
|
||||
issue_uuid: [
|
||||
Suggestion(
|
||||
type=SuggestionType.ENABLE_NTP,
|
||||
context=ContextType.SYSTEM,
|
||||
reference=None,
|
||||
uuid=(sugg_uuid := uuid4()),
|
||||
auto=False,
|
||||
),
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert await async_setup_component(hass, "hassio", {})
|
||||
|
||||
repair_issue = issue_registry.async_get_issue(
|
||||
domain="hassio", issue_id=issue_uuid.hex
|
||||
)
|
||||
assert repair_issue
|
||||
|
||||
client = await hass_client()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/repairs/issues/fix",
|
||||
json={"handler": "hassio", "issue_id": repair_issue.issue_id},
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"type": "form",
|
||||
"flow_id": flow_id,
|
||||
"handler": "hassio",
|
||||
"step_id": "system_enable_ntp",
|
||||
"data_schema": [],
|
||||
"errors": None,
|
||||
"description_placeholders": None,
|
||||
"last_step": True,
|
||||
"preview": None,
|
||||
}
|
||||
|
||||
resp = await client.post(f"/api/repairs/issues/fix/{flow_id}")
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"type": "create_entry",
|
||||
"flow_id": flow_id,
|
||||
"handler": "hassio",
|
||||
"description": None,
|
||||
"description_placeholders": None,
|
||||
}
|
||||
|
||||
assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex)
|
||||
supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("all_setup_requests")
|
||||
async def test_supervisor_issue_ntp_sync_failed_repair_flow_error(
|
||||
hass: HomeAssistant,
|
||||
supervisor_client: AsyncMock,
|
||||
hass_client: ClientSessionGenerator,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test fix flow aborts when NTP re-enable fails."""
|
||||
mock_resolution_info(
|
||||
supervisor_client,
|
||||
issues=[
|
||||
Issue(
|
||||
type=IssueType.NTP_SYNC_FAILED,
|
||||
context=ContextType.SYSTEM,
|
||||
reference=None,
|
||||
uuid=(issue_uuid := uuid4()),
|
||||
),
|
||||
],
|
||||
suggestions_by_issue={
|
||||
issue_uuid: [
|
||||
Suggestion(
|
||||
type=SuggestionType.ENABLE_NTP,
|
||||
context=ContextType.SYSTEM,
|
||||
reference=None,
|
||||
uuid=uuid4(),
|
||||
auto=False,
|
||||
),
|
||||
]
|
||||
},
|
||||
suggestion_result=SupervisorError("boom"),
|
||||
)
|
||||
|
||||
assert await async_setup_component(hass, "hassio", {})
|
||||
|
||||
repair_issue = issue_registry.async_get_issue(
|
||||
domain="hassio", issue_id=issue_uuid.hex
|
||||
)
|
||||
assert repair_issue
|
||||
|
||||
client = await hass_client()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/repairs/issues/fix",
|
||||
json={"handler": "hassio", "issue_id": repair_issue.issue_id},
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
flow_id = data["flow_id"]
|
||||
|
||||
resp = await client.post(f"/api/repairs/issues/fix/{flow_id}")
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"type": "abort",
|
||||
"flow_id": flow_id,
|
||||
"handler": "hassio",
|
||||
"reason": "apply_suggestion_fail",
|
||||
"description_placeholders": None,
|
||||
}
|
||||
|
||||
assert issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("all_setup_requests")
|
||||
async def test_mount_failed_repair_flow_error(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -85,7 +85,7 @@ async def test_http_handle_intent(
|
||||
},
|
||||
"language": hass.config.language,
|
||||
"response_type": intent.IntentResponseType.ACTION_DONE.value,
|
||||
"data": {"success": [], "failed": []},
|
||||
"data": {"targets": [], "success": [], "failed": []},
|
||||
}
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ async def test_http_language_device_satellite_id(
|
||||
},
|
||||
"language": language,
|
||||
"response_type": "action_done",
|
||||
"data": {"success": [], "failed": []},
|
||||
"data": {"targets": [], "success": [], "failed": []},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1142,6 +1142,7 @@ async def test_webhook_handle_conversation_process(
|
||||
},
|
||||
"language": hass.config.language,
|
||||
"data": {
|
||||
"targets": [],
|
||||
"success": [],
|
||||
"failed": [],
|
||||
},
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
failed_results=list([
|
||||
]),
|
||||
intent=None,
|
||||
intent_targets=list([
|
||||
]),
|
||||
language='en',
|
||||
matched_states=list([
|
||||
]),
|
||||
|
||||
@@ -499,7 +499,7 @@ async def test_history_conversion(
|
||||
"speech": {"plain": {"speech": "4:24 PM", "extra_data": None}},
|
||||
"response_type": "action_done",
|
||||
"speech_slots": {"time": datetime.time(16, 24, 17, 813343)},
|
||||
"data": {"success": [], "failed": []},
|
||||
"data": {"targets": [], "success": [], "failed": []},
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -547,7 +547,7 @@ async def test_history_conversion(
|
||||
),
|
||||
Message(
|
||||
role="tool",
|
||||
content='{"speech":{"plain":{"speech":"4:24 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"16:24:17.813343"},"data":{"success":[],"failed":[]}}',
|
||||
content='{"speech":{"plain":{"speech":"4:24 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"16:24:17.813343"},"data":{"targets":[],"success":[],"failed":[]}}',
|
||||
),
|
||||
Message(role="assistant", content="4:24 PM"),
|
||||
Message(role="user", content="test message"),
|
||||
|
||||
@@ -163,6 +163,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'response_type': 'action_done',
|
||||
'speech': dict({
|
||||
@@ -256,7 +258,7 @@
|
||||
]),
|
||||
}),
|
||||
dict({
|
||||
'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}',
|
||||
'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"targets":[],"success":[],"failed":[]}}',
|
||||
'role': 'tool',
|
||||
'tool_call_id': 'mock_tool_call_id',
|
||||
}),
|
||||
|
||||
@@ -116,7 +116,7 @@ async def test_function_call(
|
||||
"speech": {"plain": {"speech": "12:00 PM", "extra_data": None}},
|
||||
"response_type": "action_done",
|
||||
"speech_slots": {"time": datetime.time(12, 0)},
|
||||
"data": {"success": [], "failed": []},
|
||||
"data": {"targets": [], "success": [], "failed": []},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
'targets': list([
|
||||
]),
|
||||
}),
|
||||
'response_type': 'action_done',
|
||||
'speech': dict({
|
||||
@@ -195,7 +197,7 @@
|
||||
}),
|
||||
dict({
|
||||
'call_id': 'mock-tool-call-id',
|
||||
'output': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}',
|
||||
'output': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"targets":[],"success":[],"failed":[]}}',
|
||||
'type': 'function_call_output',
|
||||
}),
|
||||
dict({
|
||||
|
||||
@@ -279,7 +279,7 @@ async def test_function_call(
|
||||
"speech": {"plain": {"speech": "12:00 PM", "extra_data": None}},
|
||||
"response_type": "action_done",
|
||||
"speech_slots": {"time": datetime.time(12, 0, 0, 0)},
|
||||
"data": {"success": [], "failed": []},
|
||||
"data": {"targets": [], "success": [], "failed": []},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -372,38 +372,6 @@ async def test_delete_removed_device(
|
||||
assert device_entry is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True)
|
||||
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
|
||||
async def test_update_device_firmware(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_smile_adam_heat_cool: MagicMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test device firmware update via coordinator."""
|
||||
data = mock_smile_adam_heat_cool.async_update.return_value
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "da224107914542988a88561b4452b0f6")}
|
||||
)
|
||||
assert device_entry is not None
|
||||
assert str(device_entry.sw_version) == "3.9.0"
|
||||
|
||||
data["da224107914542988a88561b4452b0f6"]["firmware"] = "3.10.13"
|
||||
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
|
||||
freezer.tick(timedelta(minutes=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "da224107914542988a88561b4452b0f6")}
|
||||
)
|
||||
assert device_entry is not None
|
||||
assert str(device_entry.sw_version) == "3.10.13"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True)
|
||||
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
|
||||
async def test_update_interval_adam(
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# serializer version: 1
|
||||
# name: test_number_empty[zoe_40][number.reg_zoe_40_minimum_charge_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 45,
|
||||
@@ -62,9 +61,8 @@
|
||||
# ---
|
||||
# name: test_number_empty[zoe_40][number.reg_zoe_40_target_charge_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 100,
|
||||
@@ -123,9 +121,8 @@
|
||||
# ---
|
||||
# name: test_number_errors[zoe_40][number.reg_zoe_40_minimum_charge_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 45,
|
||||
@@ -184,9 +181,8 @@
|
||||
# ---
|
||||
# name: test_number_errors[zoe_40][number.reg_zoe_40_target_charge_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 100,
|
||||
@@ -245,9 +241,8 @@
|
||||
# ---
|
||||
# name: test_numbers[zoe_40][number.reg_zoe_40_minimum_charge_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 45,
|
||||
@@ -306,9 +301,8 @@
|
||||
# ---
|
||||
# name: test_numbers[zoe_40][number.reg_zoe_40_target_charge_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max': 100,
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Test schedule conditions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.schedule.const import DOMAIN
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_schedules(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple schedule entities associated with different targets."""
|
||||
return await target_entities(hass, DOMAIN)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"condition",
|
||||
[
|
||||
"schedule.is_off",
|
||||
"schedule.is_on",
|
||||
],
|
||||
)
|
||||
async def test_schedule_conditions_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
|
||||
) -> None:
|
||||
"""Test the schedule conditions are gated by the labs flag."""
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states_any(
|
||||
condition="schedule.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
*parametrize_condition_states_any(
|
||||
condition="schedule.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_schedule_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_schedules: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the schedule state condition with the 'any' behavior."""
|
||||
await assert_condition_behavior_any(
|
||||
hass,
|
||||
target_entities=target_schedules,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states_all(
|
||||
condition="schedule.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
*parametrize_condition_states_all(
|
||||
condition="schedule.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_schedule_state_condition_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
target_schedules: dict[str, list[str]],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the schedule state condition with the 'all' behavior."""
|
||||
await assert_condition_behavior_all(
|
||||
hass,
|
||||
target_entities=target_schedules,
|
||||
condition_target_config=condition_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
condition=condition,
|
||||
condition_options=condition_options,
|
||||
states=states,
|
||||
)
|
||||
@@ -549,506 +549,6 @@
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_000001][button.dishwasher_cancel-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.dishwasher_cancel',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Cancel',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Cancel',
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cancel',
|
||||
'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_samsungce.dishwasherOperation_cancel_drain',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_000001][button.dishwasher_cancel-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher Cancel',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dishwasher_cancel',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_000001][button.dishwasher_cancel_and_drain-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.dishwasher_cancel_and_drain',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Cancel and drain',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Cancel and drain',
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cancel_and_drain',
|
||||
'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_custom.supportedOptions_setCourse_89',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_000001][button.dishwasher_cancel_and_drain-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher Cancel and drain',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dishwasher_cancel_and_drain',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_000001][button.dishwasher_pause-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.dishwasher_pause',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Pause',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Pause',
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'pause',
|
||||
'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_samsungce.dishwasherOperation_pause',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_000001][button.dishwasher_pause-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher Pause',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dishwasher_pause',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_000001][button.dishwasher_resume-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.dishwasher_resume',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Resume',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Resume',
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'resume',
|
||||
'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_samsungce.dishwasherOperation_resume',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_000001][button.dishwasher_resume-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher Resume',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dishwasher_resume',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_000001][button.dishwasher_start-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.dishwasher_start',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Start',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Start',
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'start',
|
||||
'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_samsungce.dishwasherOperation_start',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_000001][button.dishwasher_start-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher Start',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dishwasher_start',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][button.dishwasher_1_cancel-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.dishwasher_1_cancel',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Cancel',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Cancel',
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cancel',
|
||||
'unique_id': '7ff318f3-3772-524d-3c9f-72fcd26413ed_main_samsungce.dishwasherOperation_cancel_drain',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][button.dishwasher_1_cancel-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher 1 Cancel',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dishwasher_1_cancel',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][button.dishwasher_1_cancel_and_drain-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.dishwasher_1_cancel_and_drain',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Cancel and drain',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Cancel and drain',
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cancel_and_drain',
|
||||
'unique_id': '7ff318f3-3772-524d-3c9f-72fcd26413ed_main_custom.supportedOptions_setCourse_89',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][button.dishwasher_1_cancel_and_drain-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher 1 Cancel and drain',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dishwasher_1_cancel_and_drain',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][button.dishwasher_1_pause-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.dishwasher_1_pause',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Pause',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Pause',
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'pause',
|
||||
'unique_id': '7ff318f3-3772-524d-3c9f-72fcd26413ed_main_samsungce.dishwasherOperation_pause',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][button.dishwasher_1_pause-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher 1 Pause',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dishwasher_1_pause',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][button.dishwasher_1_resume-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.dishwasher_1_resume',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Resume',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Resume',
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'resume',
|
||||
'unique_id': '7ff318f3-3772-524d-3c9f-72fcd26413ed_main_samsungce.dishwasherOperation_resume',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][button.dishwasher_1_resume-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher 1 Resume',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dishwasher_1_resume',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][button.dishwasher_1_start-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': None,
|
||||
'entity_id': 'button.dishwasher_1_start',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Start',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Start',
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'start',
|
||||
'unique_id': '7ff318f3-3772-524d-3c9f-72fcd26413ed_main_samsungce.dishwasherOperation_start',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][button.dishwasher_1_start-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher 1 Start',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dishwasher_1_start',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_mf_01001][button.filtro_in_microfibra_reset_water_filter-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pysmartthings import Attribute, Capability, Command
|
||||
from pysmartthings import Capability, Command
|
||||
from pysmartthings.models import HealthStatus
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
@@ -17,15 +17,9 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import (
|
||||
set_attribute_value,
|
||||
setup_integration,
|
||||
snapshot_smartthings_entities,
|
||||
trigger_health_update,
|
||||
)
|
||||
from . import setup_integration, snapshot_smartthings_entities, trigger_health_update
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -101,65 +95,3 @@ async def test_availability_at_start(
|
||||
"""Test unavailable at boot."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert hass.states.get("button.microwave_stop").state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_fixture", ["da_wm_dw_01011"])
|
||||
async def test_turn_on_without_remote_control(
|
||||
hass: HomeAssistant,
|
||||
devices: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test state update."""
|
||||
set_attribute_value(
|
||||
devices,
|
||||
Capability.REMOTE_CONTROL_STATUS,
|
||||
Attribute.REMOTE_CONTROL_ENABLED,
|
||||
"false",
|
||||
)
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError,
|
||||
match="Can only be changed when remote control is enabled",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: "button.dishwasher_1_start"},
|
||||
blocking=True,
|
||||
)
|
||||
devices.execute_device_command.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_fixture", ["da_wm_dw_01011"])
|
||||
async def test_turn_on_with_wrong_dishwasher_machine_state(
|
||||
hass: HomeAssistant,
|
||||
devices: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test state update."""
|
||||
set_attribute_value(
|
||||
devices,
|
||||
Capability.REMOTE_CONTROL_STATUS,
|
||||
Attribute.REMOTE_CONTROL_ENABLED,
|
||||
"true",
|
||||
)
|
||||
set_attribute_value(
|
||||
devices,
|
||||
Capability.DISHWASHER_OPERATING_STATE,
|
||||
Attribute.MACHINE_STATE,
|
||||
"run",
|
||||
)
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError,
|
||||
match="Can only be updated when dishwasher machine state is stop",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: "button.dishwasher_1_start"},
|
||||
blocking=True,
|
||||
)
|
||||
devices.execute_device_command.assert_not_called()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ async def test_sensor(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test states of the sensor."""
|
||||
state = hass.states.get("sensor.test_abc_type_total_power")
|
||||
state = hass.states.get("sensor.wf_test_gwid_12345_totalunitpower")
|
||||
assert state
|
||||
assert state.state == "1500"
|
||||
|
||||
@@ -45,7 +45,7 @@ async def test_sensor(
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.test_abc_type_total_power")
|
||||
state = hass.states.get("sensor.wf_test_gwid_12345_totalunitpower")
|
||||
assert state
|
||||
assert state.state == "2000"
|
||||
|
||||
@@ -65,7 +65,7 @@ async def test_availability(
|
||||
side_effect: Exception,
|
||||
) -> None:
|
||||
"""Ensure that we mark the entities unavailable correctly when service is offline."""
|
||||
entity_id = "sensor.test_abc_type_total_power"
|
||||
entity_id = "sensor.wf_test_gwid_12345_totalunitpower"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
|
||||
@@ -386,60 +386,6 @@ async def test_setup_no_webhook(
|
||||
mock_async_generate_url.assert_called_once()
|
||||
|
||||
assert expected_message in caplog.text
|
||||
assert caplog.text.count(expected_message) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected_message"),
|
||||
[
|
||||
("http://example.com", "HTTPS is required"),
|
||||
("https://example.com:444", "port 443 is required"),
|
||||
],
|
||||
)
|
||||
async def test_setup_no_webhook_logged_once(
|
||||
hass: HomeAssistant,
|
||||
webhook_config_entry: MockConfigEntry,
|
||||
withings: AsyncMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
url: str,
|
||||
expected_message: str,
|
||||
) -> None:
|
||||
"""Test webhook warning is only logged once on repeated retries."""
|
||||
await mock_cloud(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with (
|
||||
patch("homeassistant.components.cloud.async_is_logged_in", return_value=True),
|
||||
patch.object(cloud, "async_is_connected", return_value=True),
|
||||
patch.object(cloud, "async_active_subscription", return_value=True),
|
||||
patch(
|
||||
"homeassistant.components.cloud.async_create_cloudhook",
|
||||
return_value=url,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.withings.async_get_config_entry_implementation",
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.cloud.async_delete_cloudhook",
|
||||
),
|
||||
patch("homeassistant.components.withings.webhook_generate_url"),
|
||||
):
|
||||
await setup_integration(hass, webhook_config_entry)
|
||||
await prepare_webhook_setup(hass, freezer)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert caplog.text.count(expected_message) == 1
|
||||
|
||||
# Simulate cloud disconnect then reconnect, triggering register_webhook again
|
||||
async_mock_cloud_connection_status(hass, False)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
async_mock_cloud_connection_status(hass, True)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Warning should still only be logged once
|
||||
assert caplog.text.count(expected_message) == 1
|
||||
|
||||
|
||||
async def test_cloud_disconnect(
|
||||
|
||||
@@ -249,6 +249,7 @@ async def test_assist_api(
|
||||
"data": {
|
||||
"failed": [],
|
||||
"success": [],
|
||||
"targets": [],
|
||||
},
|
||||
"reprompt": {
|
||||
"plain": {
|
||||
@@ -307,6 +308,7 @@ async def test_assist_api(
|
||||
"data": {
|
||||
"failed": [],
|
||||
"success": [],
|
||||
"targets": [],
|
||||
},
|
||||
"response_type": "action_done",
|
||||
"reprompt": {
|
||||
|
||||
@@ -297,24 +297,6 @@ def test_device_selector_schema_error(schema) -> None:
|
||||
("light.abc123", "blah.blah", FAKE_UUID),
|
||||
(None,),
|
||||
),
|
||||
(
|
||||
{
|
||||
"filter": [
|
||||
{"unit_of_measurement": "baguette"},
|
||||
]
|
||||
},
|
||||
("light.abc123", "blah.blah", FAKE_UUID),
|
||||
(None,),
|
||||
),
|
||||
(
|
||||
{
|
||||
"filter": [
|
||||
{"unit_of_measurement": ["currywurst", "bratwurst"]},
|
||||
]
|
||||
},
|
||||
("light.abc123", "blah.blah", FAKE_UUID),
|
||||
(None,),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> None:
|
||||
@@ -337,10 +319,6 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) ->
|
||||
{"filter": [{"supported_features": ["light.LightEntityFeature.blah"]}]},
|
||||
# supported_features should be used under the filter key
|
||||
{"supported_features": ["light.LightEntityFeature.EFFECT"]},
|
||||
# unit_of_measurement should be used under the filter key
|
||||
{"unit_of_measurement": ["currywurst", "bratwurst"]},
|
||||
# Invalid unit_of_measurement
|
||||
{"filter": [{"unit_of_measurement": 42}]},
|
||||
# reorder can only be used when multiple is true
|
||||
{"reorder": True},
|
||||
{"reorder": True, "multiple": False},
|
||||
@@ -883,11 +861,6 @@ def test_time_selector_schema(schema, valid_selections, invalid_selections) -> N
|
||||
(),
|
||||
(),
|
||||
),
|
||||
(
|
||||
{"attribute": "best_attribute"},
|
||||
(),
|
||||
(),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_state_selector_schema(schema, valid_selections, invalid_selections) -> None:
|
||||
|
||||
Reference in New Issue
Block a user