Compare commits

...

22 Commits

Author SHA1 Message Date
J. Nick Koston
f73502c77a Bump ulid-transform to 2.2.0 (#165964) 2026-03-18 23:15:26 +01:00
Dan Raper
2c37a86bc9 Bump ohme to 1.7.1 (#165951) 2026-03-18 21:47:48 +00:00
tronikos
fa8e976de7 Add exception translations to Google Weather (#165935)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 13:25:58 -07:00
Andres Ruiz
877bca28ad Stop manually assigning an entity_id in waterfurnace sensors (#165954) 2026-03-18 20:58:36 +01:00
tronikos
a57c65f512 Add reconfigure flow in Google Drive (#165926) 2026-03-18 12:46:43 -07:00
tronikos
7140826dbb Do not abbreviate "reauthentication" in Google Drive (#165941) 2026-03-18 20:38:49 +01:00
Bouwe Westerdijk
5fea8d69d7 Add live firmware update detection to Plugwise (#165936)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 20:37:57 +01:00
Paul Tarjan
98e3b9962e Log Withings webhook URL warning only once (#164551)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 20:21:38 +01:00
Kurt Chrisford
afe19147f8 Test coverage for the Actron Air integration (#164446) 2026-03-18 20:20:51 +01:00
Willem-Jan van Rootselaar
0e7c25488c Add reconfigure flow to BSB-LAN (#164070) 2026-03-18 20:19:50 +01:00
Jan Čermák
412e85203d Add issue and repair for NTP sync failure (#165463)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-03-18 20:16:46 +01:00
Abílio Costa
55ec4a95fd Update renault snapshots (#165948) 2026-03-18 19:59:39 +01:00
Artur Pragacz
6ea9e9a161 Remove targets from intent response (#165434)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-18 18:35:30 +00:00
tronikos
b56e6d1ff7 Update Google Drive quality scale rules to match #156167 (#165916) 2026-03-18 19:34:55 +01:00
Eduardo Tsen
b502cdd15b Add buttons for controlling dishwasher operation (#160269)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-18 19:32:58 +01:00
Mike Ryan
b7ba85192d Add Trigger Motion Activity button to fully kiosk browser (#164499)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-18 14:24:51 -04:00
Erik Montnemery
04d45c8ada Add schedule conditions (#165913)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-18 18:55:47 +01:00
tronikos
ba0804fefa Add exception translations to Google Drive (#165932) 2026-03-18 18:51:07 +01:00
Erik Montnemery
538b817bf1 Adjust inheritance tree of EntitySelectorConfig (#165915) 2026-03-18 18:32:44 +01:00
Brandon Rothweiler
7efa2d3cac Add Dropbox backup integration (#155644) 2026-03-18 17:58:57 +01:00
Erik Montnemery
3f872fd196 Allow specifying attribute in state selector (#165928) 2026-03-18 17:54:36 +01:00
Erik Montnemery
b00f6593f1 Add unit of measurement to entity selector filter (#165914) 2026-03-18 17:01:21 +01:00
96 changed files with 4461 additions and 1001 deletions

View File

@@ -173,6 +173,7 @@ 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
View File

@@ -397,6 +397,8 @@ 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

View File

@@ -120,7 +120,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="timeout",
)
del self.login_task
self.login_task = None
return await self.async_step_user()
async def async_step_reauth(

View File

@@ -12,6 +12,6 @@
"documentation": "https://www.home-assistant.io/integrations/actron_air",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.4.1"]
}

View File

@@ -37,7 +37,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: todo
test-coverage: done
# Gold
devices: done

View File

@@ -135,6 +135,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"motion",
"occupancy",
"person",
"schedule",
"siren",
"switch",
"vacuum",

View File

@@ -183,90 +183,122 @@ 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=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,
}
),
data_schema=self._build_credentials_schema(existing_entry.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)
# Merge existing data with user input for validation
validate_data = {**existing_entry.data, **user_input}
errors = await self._async_validate_credentials(validate_data)
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)
if errors:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self._build_credentials_schema(user_input),
errors=errors,
)
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:
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"},
)
errors["base"] = "invalid_auth"
except BSBLANError:
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"},
)
errors["base"] = "cannot_connect"
return errors
# 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
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,
}
)
@callback
@@ -274,32 +306,9 @@ 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=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,
}
),
data_schema=self._build_connection_schema(user_input or {}),
errors=errors or {},
)

View File

@@ -58,7 +58,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |

View File

@@ -3,7 +3,9 @@
"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%]"
"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."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -39,6 +41,24 @@
"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%]",

View File

@@ -0,0 +1,64 @@
"""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

View File

@@ -0,0 +1,38 @@
"""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

View File

@@ -0,0 +1,44 @@
"""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

View File

@@ -0,0 +1,230 @@
"""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")

View File

@@ -0,0 +1,60 @@
"""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()

View File

@@ -0,0 +1,19 @@
"""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"
)

View File

@@ -0,0 +1,13 @@
{
"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"]
}

View File

@@ -0,0 +1,112 @@
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

View File

@@ -0,0 +1,35 @@
{
"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%]"
}
}
}

View File

@@ -27,6 +27,7 @@ class FullyButtonEntityDescription(ButtonEntityDescription):
"""Fully Kiosk Browser button description."""
press_action: Callable[[FullyKiosk], Any]
refresh_after_press: bool = True
BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
@@ -68,6 +69,13 @@ 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,
),
)
@@ -102,4 +110,5 @@ 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)
await self.coordinator.async_refresh()
if self.entity_description.refresh_after_press:
await self.coordinator.async_refresh()

View File

@@ -88,6 +88,9 @@
},
"to_foreground": {
"name": "Bring to foreground"
},
"trigger_motion": {
"name": "Trigger motion activity"
}
},
"image": {

View File

@@ -12,6 +12,7 @@ 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,
)
@@ -30,11 +31,17 @@ _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, await async_get_config_entry_implementation(hass, entry)
),
OAuth2Session(hass, entry, implementation),
)
# Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not
@@ -46,7 +53,11 @@ 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 from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": "Home Assistant"},
) from err
def async_notify_backup_listeners() -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):

View File

@@ -22,6 +22,8 @@ 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
@@ -61,14 +63,21 @@ class AsyncConfigEntryAuth(AbstractAuth):
):
if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
translation_domain=DOMAIN,
translation_key="authentication_not_valid",
) from ex
raise ConfigEntryNotReady from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from ex
if hasattr(ex, "status") and ex.status == 400:
self._oauth_session.config_entry.async_start_reauth(
self._oauth_session.hass
)
raise HomeAssistantError(ex) from ex
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from ex
return str(self._oauth_session.token[CONF_ACCESS_TOKEN])

View File

@@ -8,7 +8,11 @@ from typing import Any, cast
from google_drive_api.exceptions import GoogleDriveApiError
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
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
@@ -44,6 +48,12 @@ 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:
@@ -81,13 +91,16 @@ class OAuth2FlowHandler(
await self.async_set_unique_id(email_address)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
if self.source == SOURCE_REAUTH:
entry = self._get_reauth_entry()
else:
entry = self._get_reconfigure_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"email": cast(str, reauth_entry.unique_id)},
description_placeholders={"email": cast(str, entry.unique_id)},
)
return self.async_update_reload_and_abort(reauth_entry, data=data)
return self.async_update_reload_and_abort(entry, data=data)
self._abort_if_unique_id_configured()

View File

@@ -17,9 +17,7 @@ rules:
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name:
status: exempt
comment: No entities.
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
@@ -66,12 +64,8 @@ rules:
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: No entities.
reconfiguration-flow:
status: exempt
comment: No configuration options.
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: No repairs.

View File

@@ -18,6 +18,7 @@
"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}."
@@ -62,5 +63,22 @@
"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%]"
}
}
}

View File

@@ -24,6 +24,8 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
T = TypeVar(
@@ -97,7 +99,13 @@ class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
self.subentry.title,
err,
)
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"error": str(err),
},
) from err
class GoogleWeatherCurrentConditionsCoordinator(

View File

@@ -66,7 +66,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:

View File

@@ -98,5 +98,10 @@
"name": "Wind gust speed"
}
}
},
"exceptions": {
"update_error": {
"message": "Error fetching weather data: {error}"
}
}
}

View File

@@ -92,6 +92,7 @@ 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__)

View File

@@ -177,6 +177,19 @@
},
"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": {

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["ohme==1.7.0"]
"requirements": ["ohme==1.7.1"]
}

View File

@@ -65,6 +65,7 @@ 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()
@@ -129,6 +130,7 @@ 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:
@@ -138,6 +140,9 @@ 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
)
@@ -149,21 +154,52 @@ 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:
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",
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",
DOMAIN,
device_entry.model,
device_id,
)
continue # pragma: no cover
device_reg.async_update_device(
device_entry.id, remove_config_entry_id=self.config_entry.entry_id
)
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)
LOGGER.debug(
"%s %s %s removed from device_registry",
"Firmware in device_registry updated for %s %s %s",
DOMAIN,
device_entry.model,
device_id,
)
return True
return False # pragma: no cover

View File

@@ -0,0 +1,17 @@
"""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

View File

@@ -0,0 +1,17 @@
.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

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_off": {
"condition": "mdi:calendar-blank"
},
"is_on": {
"condition": "mdi:calendar-clock"
}
},
"services": {
"get_schedule": {
"service": "mdi:calendar-export"

View File

@@ -1,8 +1,32 @@
{
"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%]",
@@ -25,6 +49,12 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -3,16 +3,18 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from pysmartthings import Capability, Command, SmartThings
from pysmartthings import Attribute, Capability, Category, 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 MAIN
from .const import DOMAIN, MAIN
from .entity import SmartThingsEntity
@@ -22,7 +24,11 @@ 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] = {
@@ -53,6 +59,50 @@ 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,
@@ -60,13 +110,41 @@ async def async_setup_entry(
) -> None:
"""Add button entities for a config entry."""
entry_data = entry.runtime_data
async_add_entities(
SmartThingsButtonEntity(entry_data.client, device, description, component)
entities: list[SmartThingsEntity] = []
entities.extend(
SmartThingsButtonEntity(
entry_data.client, device, description, Capability(capability), 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):
@@ -79,16 +157,53 @@ class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity):
client: SmartThings,
device: FullDevice,
entity_description: SmartThingsButtonDescription,
component: str,
capability: Capability,
component: str = MAIN,
) -> None:
"""Initialize the instance."""
super().__init__(client, device, set(), component=component)
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)
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.entity_description.key,
self.button_capability,
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}"
)

View File

@@ -27,12 +27,27 @@
}
},
"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"
}

View File

@@ -93,6 +93,15 @@
}
},
"button": {
"cancel": {
"name": "Cancel"
},
"cancel_and_drain": {
"name": "Cancel and drain"
},
"pause": {
"name": "[%key:common::action::pause%]"
},
"reset_hepa_filter": {
"name": "Reset HEPA filter"
},
@@ -102,6 +111,12 @@
"reset_water_filter": {
"name": "Reset water filter"
},
"resume": {
"name": "Resume"
},
"start": {
"name": "[%key:common::action::start%]"
},
"stop": {
"name": "[%key:common::action::stop%]"
}
@@ -1009,6 +1024,9 @@
"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": {

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from homeassistant.components.sensor import (
ENTITY_ID_FORMAT,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -19,7 +18,6 @@ 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
@@ -178,10 +176,6 @@ 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(

View File

@@ -214,6 +214,7 @@ 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:
@@ -260,16 +261,20 @@ class WithingsWebhookManager:
)
url = URL(webhook_url)
if url.scheme != "https":
LOGGER.warning(
"Webhook not registered - HTTPS is required. "
"See https://www.home-assistant.io/integrations/withings/#webhook-requirements"
)
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
return
if url.port != 443:
LOGGER.warning(
"Webhook not registered - port 443 is required. "
"See https://www.home-assistant.io/integrations/withings/#webhook-requirements"
)
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
return
webhook_name = "Withings"

View File

@@ -6,6 +6,7 @@ To update, run python3 -m script.hassfest
APPLICATION_CREDENTIALS = [
"aladdin_connect",
"august",
"dropbox",
"ekeybionyx",
"electric_kiwi",
"fitbit",

View File

@@ -158,6 +158,7 @@ FLOWS = {
"downloader",
"dremel_3d_printer",
"drop_connect",
"dropbox",
"droplet",
"dsmr",
"dsmr_reader",

View File

@@ -1473,6 +1473,12 @@
"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",

View File

@@ -1371,7 +1371,6 @@ 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] = []
@@ -1421,14 +1420,6 @@ 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,
@@ -1474,11 +1465,6 @@ 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
]

View File

@@ -165,10 +165,23 @@ 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.
@@ -190,6 +203,7 @@ 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(
@@ -888,9 +902,15 @@ class DurationSelector(Selector[DurationSelectorConfig]):
return cast(dict[str, float], data)
class EntitySelectorConfig(BaseSelectorConfig, EntityFilterSelectorConfig, total=False):
class EntitySelectorConfig(
BaseSelectorConfig, _LegacyEntityFilterSelectorConfig, 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
@@ -1508,6 +1528,7 @@ class StateSelectorConfig(BaseSelectorConfig, total=False):
entity_id: str
hide_states: list[str]
attribute: str
multiple: bool
@@ -1530,11 +1551,7 @@ class StateSelector(Selector[StateSelectorConfig]):
{
vol.Optional("entity_id"): cv.entity_id,
vol.Optional("hide_states"): [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("attribute"): str,
vol.Optional("multiple", default=False): cv.boolean,
}
)

View File

@@ -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.0.2
ulid-transform==2.2.0
urllib3>=2.0
uv==0.10.6
voluptuous-openapi==0.2.0

10
mypy.ini generated
View File

@@ -1486,6 +1486,16 @@ 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

View File

@@ -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.0.2",
"ulid-transform==2.2.0",
"urllib3>=2.0",
"uv==0.10.6",
"voluptuous==0.15.2",

2
requirements.txt generated
View File

@@ -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.0.2
ulid-transform==2.2.0
urllib3>=2.0
uv==0.10.6
voluptuous-openapi==0.2.0

5
requirements_all.txt generated
View File

@@ -1672,7 +1672,7 @@ odp-amsterdam==6.1.2
oemthermostat==1.1.1
# homeassistant.components.ohme
ohme==1.7.0
ohme==1.7.1
# homeassistant.components.ollama
ollama==0.5.1
@@ -2562,6 +2562,9 @@ 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

View File

@@ -1458,7 +1458,7 @@ objgraph==3.5.0
odp-amsterdam==6.1.2
# homeassistant.components.ohme
ohme==1.7.0
ohme==1.7.1
# homeassistant.components.ollama
ollama==0.5.1
@@ -2179,6 +2179,9 @@ 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

View File

@@ -258,3 +258,61 @@ 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"

View File

@@ -252,3 +252,84 @@ 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"

View File

@@ -0,0 +1,58 @@
"""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

View File

@@ -0,0 +1,38 @@
"""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

View File

@@ -1245,8 +1245,6 @@
failed_results=list([
]),
intent=None,
intent_targets=list([
]),
language='en',
matched_states=list([
]),

View File

@@ -112,8 +112,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -347,8 +345,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -579,8 +575,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -658,8 +652,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -712,8 +704,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -766,8 +756,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',

View File

@@ -661,8 +661,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',

View File

@@ -65,7 +65,6 @@ async def test_broadcast_intent(
"type": intent.IntentResponseTargetType.ENTITY,
},
],
"targets": [],
},
"language": "en",
"response_type": "action_done",
@@ -98,7 +97,6 @@ async def test_broadcast_intent(
"type": intent.IntentResponseTargetType.ENTITY,
},
],
"targets": [],
},
"language": "en",
"response_type": "action_done",
@@ -130,7 +128,6 @@ async def test_broadcast_intent_excluded_domains(
"data": {
"failed": [],
"success": [], # no satellites
"targets": [],
},
"language": "en",
"response_type": "action_done",

View File

@@ -5,6 +5,7 @@ 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
@@ -236,7 +237,8 @@ async def test_authentication_error(
assert port_field.default() == 8080
assert passkey_field.default() == "secret"
assert username_field.default() == "testuser"
assert password_field.default() == "wrongpassword"
# Password should never be pre-filled for security reasons
assert password_field.default is vol.UNDEFINED
async def test_authentication_error_vs_connection_error(
@@ -1059,3 +1061,128 @@ 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")

View File

@@ -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": {"targets": [], "success": [], "failed": []},
"data": {"success": [], "failed": []},
},
)
)

View File

@@ -11,8 +11,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en-us',
'response_type': 'action_done',
@@ -37,8 +35,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en-us',
'response_type': 'action_done',
@@ -63,8 +59,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -94,8 +88,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -125,8 +117,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -198,8 +188,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -229,8 +217,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -260,8 +246,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -291,8 +275,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -343,8 +325,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -416,8 +396,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -468,8 +446,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -499,8 +475,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',

View File

@@ -283,8 +283,6 @@
'type': 'entity',
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -314,8 +312,6 @@
'type': 'entity',
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',

View File

@@ -11,8 +11,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'test-language',
'response_type': 'action_done',
@@ -63,8 +61,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -94,8 +90,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -125,8 +119,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -156,8 +148,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -187,8 +177,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -218,8 +206,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -249,8 +235,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
@@ -280,8 +264,6 @@
'type': <IntentResponseTargetType.ENTITY: 'entity'>,
}),
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',

View File

@@ -0,0 +1 @@
"""Tests for the Dropbox integration."""

View File

@@ -0,0 +1,114 @@
"""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

View File

@@ -0,0 +1,577 @@
"""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

View File

@@ -0,0 +1,210 @@
"""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

View File

@@ -0,0 +1,100 @@
"""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

View File

@@ -19,6 +19,7 @@ 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"
@@ -29,7 +30,10 @@ 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"
@@ -40,7 +44,10 @@ 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"
@@ -51,7 +58,10 @@ 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"
@@ -62,7 +72,10 @@ 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"
@@ -73,7 +86,10 @@ 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"
@@ -84,6 +100,24 @@ 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)

View File

@@ -310,6 +310,94 @@ 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,

View File

@@ -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
from unittest.mock import AsyncMock, MagicMock, patch
from google_drive_api.exceptions import GoogleDriveApiError
import pytest
@@ -12,6 +12,9 @@ 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
@@ -154,3 +157,20 @@ 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

View File

@@ -28,8 +28,7 @@
response={
'data': {
'failed': [],
'success': [],
'targets': []
'success': []
},
'response_type': 'action_done',
'speech': {

View File

@@ -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": {"targets": [], "success": [], "failed": []},
"data": {"success": [], "failed": []},
},
)
)

View File

@@ -42,16 +42,19 @@ 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()
).side_effect = GoogleWeatherApiError("API error")
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,

View File

@@ -949,6 +949,61 @@ 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,

View File

@@ -402,6 +402,152 @@ 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,

View File

@@ -85,7 +85,7 @@ async def test_http_handle_intent(
},
"language": hass.config.language,
"response_type": intent.IntentResponseType.ACTION_DONE.value,
"data": {"targets": [], "success": [], "failed": []},
"data": {"success": [], "failed": []},
}
@@ -149,7 +149,7 @@ async def test_http_language_device_satellite_id(
},
"language": language,
"response_type": "action_done",
"data": {"targets": [], "success": [], "failed": []},
"data": {"success": [], "failed": []},
}

View File

@@ -1142,7 +1142,6 @@ async def test_webhook_handle_conversation_process(
},
"language": hass.config.language,
"data": {
"targets": [],
"success": [],
"failed": [],
},

View File

@@ -10,8 +10,6 @@
failed_results=list([
]),
intent=None,
intent_targets=list([
]),
language='en',
matched_states=list([
]),

View File

@@ -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": {"targets": [], "success": [], "failed": []},
"data": {"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":{"targets":[],"success":[],"failed":[]}}',
content='{"speech":{"plain":{"speech":"4:24 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"16:24:17.813343"},"data":{"success":[],"failed":[]}}',
),
Message(role="assistant", content="4:24 PM"),
Message(role="user", content="test message"),

View File

@@ -163,8 +163,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'response_type': 'action_done',
'speech': dict({
@@ -258,7 +256,7 @@
]),
}),
dict({
'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"targets":[],"success":[],"failed":[]}}',
'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}',
'role': 'tool',
'tool_call_id': 'mock_tool_call_id',
}),

View File

@@ -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": {"targets": [], "success": [], "failed": []},
"data": {"success": [], "failed": []},
},
)
)

View File

@@ -71,8 +71,6 @@
]),
'success': list([
]),
'targets': list([
]),
}),
'response_type': 'action_done',
'speech': dict({
@@ -197,7 +195,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":{"targets":[],"success":[],"failed":[]}}',
'output': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}',
'type': 'function_call_output',
}),
dict({

View File

@@ -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": {"targets": [], "success": [], "failed": []},
"data": {"success": [], "failed": []},
},
)
)

View File

@@ -372,6 +372,38 @@ 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(

View File

@@ -1,8 +1,9 @@
# serializer version: 1
# name: test_number_empty[zoe_40][number.reg_zoe_40_minimum_charge_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 45,
@@ -61,8 +62,9 @@
# ---
# name: test_number_empty[zoe_40][number.reg_zoe_40_target_charge_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 100,
@@ -121,8 +123,9 @@
# ---
# name: test_number_errors[zoe_40][number.reg_zoe_40_minimum_charge_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 45,
@@ -181,8 +184,9 @@
# ---
# name: test_number_errors[zoe_40][number.reg_zoe_40_target_charge_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 100,
@@ -241,8 +245,9 @@
# ---
# name: test_numbers[zoe_40][number.reg_zoe_40_minimum_charge_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 45,
@@ -301,8 +306,9 @@
# ---
# name: test_numbers[zoe_40][number.reg_zoe_40_target_charge_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 100,

View File

@@ -0,0 +1,126 @@
"""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,
)

View File

@@ -549,6 +549,506 @@
'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([

View File

@@ -3,7 +3,7 @@
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
from pysmartthings import Capability, Command
from pysmartthings import Attribute, Capability, Command
from pysmartthings.models import HealthStatus
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -17,9 +17,15 @@ 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 setup_integration, snapshot_smartthings_entities, trigger_health_update
from . import (
set_attribute_value,
setup_integration,
snapshot_smartthings_entities,
trigger_health_update,
)
from tests.common import MockConfigEntry
@@ -95,3 +101,65 @@ 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

View File

@@ -36,7 +36,7 @@ async def test_sensor(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test states of the sensor."""
state = hass.states.get("sensor.wf_test_gwid_12345_totalunitpower")
state = hass.states.get("sensor.test_abc_type_total_power")
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.wf_test_gwid_12345_totalunitpower")
state = hass.states.get("sensor.test_abc_type_total_power")
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.wf_test_gwid_12345_totalunitpower"
entity_id = "sensor.test_abc_type_total_power"
state = hass.states.get(entity_id)
assert state

View File

@@ -386,6 +386,60 @@ 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(

View File

@@ -249,7 +249,6 @@ async def test_assist_api(
"data": {
"failed": [],
"success": [],
"targets": [],
},
"reprompt": {
"plain": {
@@ -308,7 +307,6 @@ async def test_assist_api(
"data": {
"failed": [],
"success": [],
"targets": [],
},
"response_type": "action_done",
"reprompt": {

View File

@@ -297,6 +297,24 @@ 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:
@@ -319,6 +337,10 @@ 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},
@@ -861,6 +883,11 @@ 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: